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.

Do not disable SELinux or AppArmor "to make something work". Almost every SELinux issue is a missing label, missing boolean, or a policy that just needs a denial-driven adjustment. Disabling MAC is throwing away a layer of defence that has stopped real-world container escapes and privilege-escalation chains.

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 /proc with zeros, frustrating local KASLR bypasses.
  • kernel.yama.ptrace_scope = 1 — Restricts ptrace to descendants, blocking trivial credential theft between user processes.
  • fs.protected_* — Closes classic symlink/hardlink-in-/tmp race 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 chrony or systemd-timesyncd. Skewed clocks break TLS, Kerberos, and log correlation.
  • Login banner: Populate /etc/issue.net and reference it with Banner /etc/issue.net in sshd_config. Most jurisdictions want an "authorised use only" notice for evidentiary purposes.
  • Sudo hygiene: Use group-based access (%wheel on RHEL, %sudo on Debian); avoid NOPASSWD except for tightly-scoped automation.
  • Drop /tmp exec: If your workloads allow, mount /tmp with nodev,nosuid,noexec via /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
  • AllowUsers restricts SSH to a known list
  • ✓ Host firewall (ufw or firewalld) default-deny incoming, explicit allows
  • fail2ban running with sshd jail
  • ✓ Unattended security updates configured for your distro family
  • ✓ AppArmor enforce / SELinux enforcing — not disabled
  • auditd with rules for /etc/passwd, /etc/shadow, sudoers, sshd_config
  • ✓ sysctl hardening file present and loaded
  • ss -tulpn shows 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.