Linux Server Hardening Checklist
⚠️ Apply on systems you control
This guide assumes you are hardening a server you own or administer. Always test changes in a non-production environment first — a bad SSH or firewall rule can lock you out. Keep a console / out-of-band session open while making the changes in this guide.
A freshly installed Linux server is rarely production-ready. Default configurations favour convenience over security: root may be reachable via SSH password, the firewall is often open, package updates are not automatic, and kernel knobs are tuned for compatibility rather than defence-in-depth. In this guide we'll walk through a pragmatic hardening baseline that works on both Debian/Ubuntu and RHEL-family distributions (Rocky Linux, AlmaLinux, RHEL).
We'll focus on changes that give the largest return for the smallest amount of risk: locking down SSH, enabling a host firewall, automating security updates, embracing the mandatory access control framework that ships with your distro, enabling system auditing, tightening kernel parameters, and removing services we don't need.
SSH Configuration Hardening
SSH is almost always the front door of a Linux server, so this is where we start. All directives below live in /etc/ssh/sshd_config (see sshd_config(5)). Edit, then run sudo sshd -t to syntax-check before reloading.
Real-World Scenario
You stand up a $5/month VPS to host a small web service. Within minutes of being online, the auth log fills with brute-force attempts against root, admin, ubuntu and oracle. Disabling password auth and root login closes off the majority of these without writing a single firewall rule.
Generate a modern key first
Before disabling password authentication, make sure you have a working key pair. We prefer Ed25519 over RSA for new keys: it's faster, smaller and resistant to weak-RNG and small-modulus issues.
Create and copy a key (on your workstation)
# Generate a modern key pair ssh-keygen -t ed25519 -a 100 -C "alex@workstation" # Copy the public key to the server (still using password auth at this stage) ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected] # Test that key auth works before locking down passwords ssh -i ~/.ssh/id_ed25519 [email protected]
Core sshd_config directives
/etc/ssh/sshd_config — opinionated baseline
# Identity Port 22 AddressFamily any Protocol 2 # Authentication PermitRootLogin no PasswordAuthentication no PermitEmptyPasswords no ChallengeResponseAuthentication no KbdInteractiveAuthentication no UsePAM yes PubkeyAuthentication yes MaxAuthTries 3 LoginGraceTime 30 AuthenticationMethods publickey # Restrict which accounts can log in at all AllowUsers alex deploy # Disable agent / TCP / X11 forwarding unless you actually need them AllowAgentForwarding no AllowTcpForwarding no X11Forwarding no # Modern crypto only — drop anything older than these KexAlgorithms curve25519-sha256,[email protected],[email protected] Ciphers [email protected],[email protected],[email protected] MACs [email protected],[email protected] # Idle session timeout (5 minutes, drop after 2 missed responses) ClientAliveInterval 300 ClientAliveCountMax 2
The KexAlgorithms, Ciphers and MACs lines above intentionally exclude older algorithms such as diffie-hellman-group14-sha1 and CBC ciphers. The [email protected] KEX is a post-quantum hybrid available in OpenSSH 9.0+; on older releases just remove it.
Reload and verify
Apply the change without dropping your current session
# Syntax-check first — never skip this sudo sshd -t # Reload (does not kill existing sessions) sudo systemctl reload sshd # From a *new* shell, confirm you can still log in ssh [email protected] # Inspect the negotiated cipher ssh -v [email protected] 2>&1 | grep -E 'kex:|cipher:'
Tip: Keep your existing SSH session open until you've confirmed the new one works. If you typo a directive, that first session is your lifeline.
Host Firewall with UFW
On Debian/Ubuntu, UFW is a friendly front-end for nftables/iptables. On RHEL-family distributions, the equivalent is firewalld; we'll cover UFW here because the questions in the prompt ask for it, but the principle is the same: default deny inbound, default allow outbound, allow only what you need.
UFW baseline
# Install on Debian / Ubuntu (already present on most server images) sudo apt-get install -y ufw # Default policy sudo ufw default deny incoming sudo ufw default allow outgoing # Allow and rate-limit SSH (6 connections / 30s per source IP) sudo ufw limit 22/tcp comment 'SSH with rate-limit' # Web traffic if applicable sudo ufw allow 80/tcp comment 'HTTP' sudo ufw allow 443/tcp comment 'HTTPS' # Enable and inspect sudo ufw enable sudo ufw status verbose sudo ufw status numbered
ufw limit uses kernel connection tracking to drop sources that open more than 6 connections in 30 seconds. It is not a replacement for fail2ban, but it raises the cost of trivial scans.
Example output of ufw status verbose:
Status: active Logging: on (low) Default: deny (incoming), allow (outgoing), disabled (routed) New profiles: skip To Action From -- ------ ---- 22/tcp LIMIT Anywhere 80/tcp ALLOW Anywhere 443/tcp ALLOW Anywhere 22/tcp (v6) LIMIT Anywhere (v6)
fail2ban for sshd
fail2ban watches log files, matches failed authentication patterns, and inserts temporary firewall rules to block the offending source. Always edit jail.local (which overrides shipped defaults) rather than jail.conf (which is replaced on package upgrades).
Install and configure
# Debian / Ubuntu sudo apt-get install -y fail2ban # RHEL / Rocky / AlmaLinux (needs EPEL) sudo dnf install -y epel-release sudo dnf install -y fail2ban # Create the override file sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF' [DEFAULT] # Trust your own admin network so a typo doesn't lock you out ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24 bantime = 1h findtime = 10m maxretry = 5 backend = systemd [sshd] enabled = true port = ssh filter = sshd maxretry = 3 bantime = 24h EOF sudo systemctl enable --now fail2ban # Check the jail sudo fail2ban-client status sudo fail2ban-client status sshd
The 203.0.113.0/24 range above is the IANA documentation block (RFC 5737) — replace it with the network you actually administer from. The backend = systemd line tells fail2ban to read from the systemd journal rather than tail /var/log/auth.log, which is more reliable on modern distributions.
Automatic Security Updates
An unpatched server is a vulnerable server. The mechanism differs by distro family, but the goal is the same: apply at least security errata without manual intervention.
On Ubuntu / Debian
unattended-upgrades
sudo apt-get install -y unattended-upgrades apt-listchanges # Interactive setup — pick "Yes" to the prompt sudo dpkg-reconfigure --priority=low unattended-upgrades # Inspect the policy file sudoedit /etc/apt/apt.conf.d/50unattended-upgrades # Verify what the scheduler thinks sudo unattended-upgrade --dry-run --debug # Reboots are off by default. Allow them in a maintenance window: # Unattended-Upgrade::Automatic-Reboot "true"; # Unattended-Upgrade::Automatic-Reboot-Time "03:30";
On RHEL / Rocky / AlmaLinux
dnf-automatic
sudo dnf install -y dnf-automatic sudoedit /etc/dnf/automatic.conf # Recommended: # [commands] # upgrade_type = security # apply_updates = yes # # [emitters] # emit_via = stdio,motd # Enable the timer (NOT the .service unit) sudo systemctl enable --now dnf-automatic.timer systemctl list-timers --all | grep dnf-automatic
Both mechanisms log to standard locations: /var/log/unattended-upgrades/ on Debian/Ubuntu and the systemd journal (journalctl -u dnf-automatic) on RHEL.
Mandatory Access Control: AppArmor & SELinux
Mandatory Access Control (MAC) confines processes by policy, independent of the user that started them. Ubuntu and SUSE ship AppArmor; RHEL-family distributions ship SELinux. Both are good; what matters is leaving them enabled and in enforcing mode.
AppArmor (Ubuntu / Debian)
Inspect profile state
sudo aa-status # Example output: # apparmor module is loaded. # 32 profiles are loaded. # 30 profiles are in enforce mode. # 2 profiles are in complain mode. # Put a profile into enforce mode sudo aa-enforce /etc/apparmor.d/usr.sbin.nginx # Tail denials sudo journalctl -k | grep -i apparmor
SELinux (RHEL / Rocky / AlmaLinux)
Confirm enforcing mode
# Quick mode check getenforce # Should print: Enforcing # Detailed status sestatus # Permissive vs enforcing temporarily (resets on reboot) sudo setenforce 1 # enforcing sudo setenforce 0 # permissive (debugging only) # Persistent setting lives here sudoedit /etc/selinux/config # SELINUX=enforcing # SELINUXTYPE=targeted # Investigate a denial without disabling SELinux sudo dnf install -y setroubleshoot-server sudo ausearch -m AVC -ts recent sudo sealert -a /var/log/audit/audit.log
If a legitimate service is being denied, the correct workflow is: read the AVC, identify the missing context, fix it (semanage fcontext + restorecon) or flip the appropriate boolean (setsebool -P). See selinux(8) and the Red Hat SELinux User's and Administrator's Guide.
Auditing with auditd
The Linux audit subsystem records kernel-level events: syscalls, file access, login activity, and rule-defined triggers. It's how you answer "did someone read /etc/shadow on Tuesday?" months after the fact.
Install and bootstrap
# Debian / Ubuntu sudo apt-get install -y auditd audispd-plugins # RHEL / Rocky / AlmaLinux (usually pre-installed) sudo dnf install -y audit sudo systemctl enable --now auditd # List active rules sudo auditctl -l # Example custom rules: watch sensitive files sudo tee /etc/audit/rules.d/hardening.rules > /dev/null <<'EOF' # Identity files -w /etc/passwd -p wa -k identity -w /etc/shadow -p wa -k identity -w /etc/group -p wa -k identity -w /etc/gshadow -p wa -k identity # sudoers and ssh config -w /etc/sudoers -p wa -k sudo -w /etc/sudoers.d/ -p wa -k sudo -w /etc/ssh/sshd_config -p wa -k sshd # Time / kernel module changes -a always,exit -F arch=b64 -S adjtimex -S settimeofday -k time-change -a always,exit -F arch=b64 -S init_module -S delete_module -k modules # Make rules immutable until reboot -e 2 EOF sudo augenrules --load sudo systemctl restart auditd
Search the resulting log with ausearch -k identity or summarise with aureport --summary. For long-term storage, ship the events off-host with the audisp remote plugin or a journald → syslog → SIEM pipeline.
sysctl Kernel Hardening
Several kernel parameters default to "permissive" for historical compatibility. We can tighten them in /etc/sysctl.d/99-hardening.conf. None of these flags break a normal workload; they reduce attacker surface for spoofing, info-leaks, and a few legacy ICMP tricks.
/etc/sysctl.d/99-hardening.conf
# --- Network: spoof & redirect protection --- net.ipv4.conf.all.rp_filter = 1 net.ipv4.conf.default.rp_filter = 1 net.ipv4.conf.all.accept_source_route = 0 net.ipv6.conf.all.accept_source_route = 0 net.ipv4.conf.all.accept_redirects = 0 net.ipv6.conf.all.accept_redirects = 0 net.ipv4.conf.all.secure_redirects = 0 net.ipv4.conf.all.send_redirects = 0 net.ipv4.conf.all.log_martians = 1 # --- ICMP / SYN --- net.ipv4.icmp_echo_ignore_broadcasts = 1 net.ipv4.icmp_ignore_bogus_error_responses = 1 net.ipv4.tcp_syncookies = 1 # --- Kernel info-leaks & pointer obfuscation --- kernel.dmesg_restrict = 1 kernel.kptr_restrict = 2 kernel.yama.ptrace_scope = 1 kernel.unprivileged_bpf_disabled = 1 net.core.bpf_jit_harden = 2 # --- Filesystem --- fs.protected_hardlinks = 1 fs.protected_symlinks = 1 fs.protected_fifos = 2 fs.protected_regular = 2 fs.suid_dumpable = 0
Apply
sudo sysctl --system # Inspect a single value sysctl kernel.dmesg_restrict sysctl net.ipv4.tcp_syncookies
What these do, briefly:
rp_filter = 1— Reverse path filtering; drops packets whose source address would not route back out the receiving interface (anti-spoof, RFC 3704).accept_redirects = 0— Ignore ICMP Redirects, which can be abused to bend traffic through an attacker.tcp_syncookies = 1— Lets the kernel survive SYN-flood saturation without dropping legitimate connections.kernel.kptr_restrict = 2— Replaces kernel pointers in/procwith zeros, frustrating local KASLR bypasses.kernel.yama.ptrace_scope = 1— Restrictsptraceto descendants, blocking trivial credential theft between user processes.fs.protected_*— Closes classic symlink/hardlink-in-/tmprace conditions.
Removing Unused Services
Every listening service is attack surface. Walk the system, identify what is running, and disable anything you can't justify.
Inventory
# What is listening on a port? sudo ss -tulpn # Enabled systemd unit files (look for unfamiliar names) systemctl list-unit-files --state=enabled # Currently active services systemctl list-units --type=service --state=running # What does a unit actually do? systemctl cat <service-name>
Disable and mask
# Stop now, never start again sudo systemctl disable --now cups.service avahi-daemon.service # Mask (link the unit to /dev/null so nothing can re-enable it) sudo systemctl mask telnet.socket # Reverse a mask if needed sudo systemctl unmask <unit>
Common services you usually don't need on a server: cups (printing), avahi-daemon (mDNS), bluetooth, ModemManager, rpcbind / nfs-* (unless you actually share filesystems). Always confirm before disabling — masking systemd-resolved on a box that needs DNS will ruin your afternoon.
Additional Quick Wins
- Time sync: Run
chronyorsystemd-timesyncd. Skewed clocks break TLS, Kerberos, and log correlation. - Login banner: Populate
/etc/issue.netand reference it withBanner /etc/issue.netinsshd_config. Most jurisdictions want an "authorised use only" notice for evidentiary purposes. - Sudo hygiene: Use group-based access (
%wheelon RHEL,%sudoon Debian); avoidNOPASSWDexcept for tightly-scoped automation. - Drop /tmp exec: If your workloads allow, mount
/tmpwithnodev,nosuid,noexecvia/etc/fstab. - Backups: Hardening only matters if you can recover. Test restores, not just backups.
Final Hardening Checklist
- ✓ Ed25519 (or hardware-token) SSH keys on every admin account
- ✓
PermitRootLogin no,PasswordAuthentication no, modern KEX/Ciphers/MACs - ✓
AllowUsersrestricts SSH to a known list - ✓ Host firewall (
ufworfirewalld) default-deny incoming, explicit allows - ✓
fail2banrunning withsshdjail - ✓ Unattended security updates configured for your distro family
- ✓ AppArmor enforce / SELinux enforcing — not disabled
- ✓
auditdwith rules for/etc/passwd,/etc/shadow, sudoers, sshd_config - ✓ sysctl hardening file present and loaded
- ✓
ss -tulpnshows only services you can justify - ✓ Time sync enabled, login banner set, sudoers reviewed
- ✓ Backups tested by an actual restore
None of these items, taken alone, will stop a determined attacker. Taken together they raise the cost of compromise enough that the average opportunistic scanner moves on, and the targeted attacker leaves more forensic traces. That is the entire point of defence in depth.