Hardening Linux servers: baseline SSH, packages, logging, sysctl
For developers who need a sane Linux server baseline without turning this into a security program. You will lock down SSH, enable automatic security updates, centralize useful logs locally with journald/rsyslog, and apply kernel/network sysctl settings that reduce common attack surface without breaking normal app workloads.
TL;DR — This baseline hardens a Linux server in the places that get abused first: SSH, unattended package patching, persistent logs, and a small set of kernel/network sysctl settings. The highest-value fix is to disable password SSH logins and root SSH access, then verify you can still log in with your key before touching anything else. Reading time: ~5 min
Goal
When you finish, your Linux server accepts SSH only with keys for an admin user, installs security updates automatically, keeps persistent system/auth logs across reboots, and boots with a conservative sysctl baseline that drops bogus packets, limits some kernel info leaks, and enables safer defaults for network handling.
Prerequisites
- Root or sudo access on a Debian 12/Ubuntu 24.04+ server — check with:
id -u
sudo -v
- OpenSSH server installed — check with:
sshd -T | head
- A working SSH public key already on the server for a non-root admin user — check with:
ssh -o BatchMode=yes adminuser@your-server 'id -un'
curl,ss,journalctl,systemctlpresent — check with:
curl --version | head -1
ss --version
journalctl --version | head -1
systemctl --version | head -1
- Debian/Ubuntu package manager available:
apt --version
- The username you want for admin SSH access, for example
adminuser - The public IPv4/IPv6 ranges that should be allowed to SSH in, if you plan to restrict by firewall separately
Steps
Step 1: Create a non-root sudo user and install your SSH key
⚠️ If you disable root/password SSH before confirming key login for another user, you can lock yourself out.
adduser adminuser
usermod -aG sudo adminuser
install -d -m 700 -o adminuser -g adminuser /home/adminuser/.ssh
install -m 600 -o adminuser -g adminuser /dev/null /home/adminuser/.ssh/authorized_keys
printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE_REPLACE_WITH_YOUR_KEY comment' >> /home/adminuser/.ssh/authorized_keys
su - adminuser -c 'chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys'
Success looks like: ssh adminuser@your-server opens a shell without asking for the root password.
Step 2: Lock down SSH daemon settings
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.$(date +%F-%H%M%S)
cat >/etc/ssh/sshd_config.d/10-hardening.conf <<'EOF'
Protocol 2
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
UsePAM yes
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
ClientAliveInterval 300
ClientAliveCountMax 2
LoginGraceTime 30
MaxAuthTries 3
MaxSessions 10
AllowUsers adminuser
LogLevel VERBOSE
EOF
sshd -t && systemctl reload ssh || systemctl reload sshd
Success looks like: sshd -t exits 0 with no output, and your current SSH session stays connected after reload.
Step 3: Verify SSH behavior from a second terminal before closing the first
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no adminuser@your-server
Expected failure:
adminuser@your-server: Permission denied (publickey).
Then verify key login still works:
ssh -o BatchMode=yes adminuser@your-server 'echo ok'
Expected output:
ok
Success looks like: password auth fails and key auth succeeds.
Step 4: Install security updates automatically
apt update
apt install -y unattended-upgrades apt-listchanges needrestart
cat >/etc/apt/apt.conf.d/52unattended-upgrades-local <<'EOF'
Unattended-Upgrade::Origins-Pattern {
"origin=${distro_id},codename=${distro_codename},label=${distro_id}-Security";
"origin=${distro_id},codename=${distro_codename}-security,label=${distro_id}-Security";
};
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
EOF
cat >/etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
systemctl enable --now unattended-upgrades
unattended-upgrade --dry-run --debug | tail -n 20
Success looks like: systemctl is-active unattended-upgrades returns active and the dry run shows packages being evaluated, not syntax errors.
Step 5: Enable persistent local logging and auth log forwarding
mkdir -p /var/log/journal
systemd-tmpfiles --create --prefix /var/log/journal
sed -i 's/^#\?Storage=.*/Storage=persistent/' /etc/systemd/journald.conf
systemctl restart systemd-journald
apt install -y rsyslog
systemctl enable --now rsyslog
journalctl --flush
journalctl -u ssh --no-pager -n 10 || journalctl -u sshd --no-pager -n 10
Success looks like: /var/log/journal exists, rsyslog is active, and SSH events appear in journalctl.
Step 6: Apply a conservative sysctl baseline
⚠️ These settings affect live networking. On unusual hosts acting as routers, load balancers, or containers with special networking, review before applying.
cat >/etc/sysctl.d/60-hardening.conf <<'EOF'
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.unprivileged_bpf_disabled = 1
kernel.yama.ptrace_scope = 1
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.suid_dumpable = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.tcp_syncookies = 1
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
EOF
sysctl --system
Success looks like: sysctl --system prints lines ending in your values and no permission denied or cannot stat errors.
Step 7: Confirm listening services and SSH exposure
ss -tulpn
systemctl --failed
last -a | head
journalctl -p warning -b --no-pager | tail -n 20
Success looks like: only expected ports are listening, systemctl --failed shows 0 loaded units listed, and there are no new SSH or sysctl-related warnings.
Verify it works
Run these checks end to end:
sshd -T | egrep 'permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|allowusers|loglevel'
systemctl is-enabled unattended-upgrades && systemctl is-active unattended-upgrades
journalctl --disk-usage
sysctl kernel.kptr_restrict kernel.dmesg_restrict kernel.unprivileged_bpf_disabled net.ipv4.tcp_syncookies net.ipv4.conf.all.accept_redirects net.ipv6.conf.all.accept_redirects
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no adminuser@your-server
Expected output shape:
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
pubkeyauthentication yes
allowusers adminuser
loglevel VERBOSE
enabled
active
Archived and active journals take up 128.0M in the file system.
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.unprivileged_bpf_disabled = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
adminuser@your-server: Permission denied (publickey).
If the server reboots, verify logs survived reboot:
journalctl --list-boots
You should see at least two boot entries once the host has rebooted.
Common pitfalls
Editing sshd_config and reloading without syntax check
Mistake: changing SSH config and running systemctl reload ssh without sshd -t.
Symptom: new SSH sessions fail; systemctl status ssh shows Failed to reload or Bad configuration option.
Fix: restore the backup and test syntax first:
cp /etc/ssh/sshd_config.bak.TIMESTAMP /etc/ssh/sshd_config
sshd -t && systemctl reload ssh || systemctl reload sshd
Disabling password auth before confirming key auth for the admin user
Mistake: setting PasswordAuthentication no while the new user has no valid key in authorized_keys.
Symptom: Permission denied (publickey) for every login path.
Fix: keep one root or console session open, add the correct public key to /home/adminuser/.ssh/authorized_keys, then retry.
Breaking SSH by using the wrong service name
Mistake: assuming the unit is always sshd.
Symptom: Failed to reload sshd.service: Unit sshd.service not found.
Fix: use the installed unit name:
systemctl list-unit-files | grep -E '^ssh(d)?\.service'
systemctl reload ssh
Applying rp_filter=1 on a host with asymmetric routing
Mistake: using strict reverse-path filtering on multi-homed hosts, some VPN setups, or policy-routed traffic.
Symptom: intermittent inbound traffic drops with no app error; SSH may work from one network and fail from another.
Fix: change to loose mode for that host:
sed -i 's/net.ipv4.conf.all.rp_filter = 1/net.ipv4.conf.all.rp_filter = 2/' /etc/sysctl.d/60-hardening.conf
sed -i 's/net.ipv4.conf.default.rp_filter = 1/net.ipv4.conf.default.rp_filter = 2/' /etc/sysctl.d/60-hardening.conf
sysctl --system
Expecting unattended-upgrades to patch everything immediately
Mistake: installing it and assuming all pending packages are upgraded right now.
Symptom: apt list --upgradable still shows packages minutes later.
Fix: run a manual cycle once, then leave the timer enabled:
apt update && unattended-upgrade -d
systemctl list-timers | grep unattended
Enabling persistent journald without disk budget
Mistake: creating /var/log/journal on a tiny root volume and never checking usage.
Symptom: root filesystem fills over time; journalctl --disk-usage keeps growing.
Fix: cap journal size explicitly:
printf '\nSystemMaxUse=500M\n' >> /etc/systemd/journald.conf
systemctl restart systemd-journald
journalctl --vacuum-size=500M
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI