Put a fresh Linux server on a public IP and check the auth log ten minutes later. You will already see it. Failed password for root from 203.0.113.7, then Invalid user oracle, then another, and another. Nobody picked your box. Bots sweep every IPv4 address on the internet around the clock, and they only need one lazy default to get in. Most of the Linux security threats in 2026 still start exactly like this.
Linux server hardening means cutting down the ways into a server: fewer open ports, no password logins, no extra root accounts, a stricter kernel, and logs that show who changed what. It isn't one tool. It's a short list of defaults you check, tighten and then prove.
The way people usually get burned here isn't a clever attacker. It's locking themselves out. Someone switches off SSH password login before their key is actually in authorized_keys, and the only way back in is the provider's slow web console. Twenty minutes of panic that a second terminal would have prevented.
So this is a short, practical Linux server hardening checklist. Twelve moves, each with a command that proves it worked, written for Ubuntu 24.04 / 26.04, Debian 12 / 13 and RHEL / Rocky Linux 9 / 10. If you want the long form version later, our full Linux server hardening checklist goes wider. This one is about getting the baseline done today.
Linux Server Hardening Checklist: Prerequisites
You don't need much. But skipping any of these is how people end up locked out of their own machine.
- A Linux server running Ubuntu 24.04 or 26.04 LTS, Debian 12 or 13, Rocky Linux 9 or 10, or RHEL 9 or 10. 1 vCPU, 1 GB RAM and 10 GB free disk is plenty for every tool here (Lynis and auditd together use well under 100 MB).
- A sudo user that is not root. Most steps change system files under
/etc, so you need sudo rights for the whole session. - Console or rescue access from your hosting panel. If SSH breaks, this is your way back. If you are still shopping for a box to practice on, pick a provider with a working web console, our roundup of the best Linux VPS hosting options flags which ones have it.
- An SSH key pair on your laptop (Step 3 creates one if you don't have it).
- Port 22/tcp reachable from your IP, plus any ports your apps really serve (usually 80 and 443).
- About 30 to 45 minutes, including one reboot.
<<'EOF'): a way to paste several lines into a file in one command, everything up to the closing EOF gets written.The 12 Linux Server Hardening Steps, With Proof
Every step follows the same loop. Check the current state, change only what is weak, then prove it. Your distro already ships with a lot of sane defaults, so blindly pasting config lines from an old guide often makes things worse, not better. Here's how the 12 moves stack up, from the outside of the server inward:
LINUX SERVER HARDENING LAYERS
Internet (bots scanning every IPv4 address)
|
+---------------------------------------------------+
| NETWORK Step 6 firewall | Step 7 closed ports |
+---------------------------------------------------+
| ACCESS Step 3 SSH keys | Step 4 crypto |
| Step 5 brute force ban |
+---------------------------------------------------+
| ACCOUNTS Step 2 one root | no NOPASSWD sudo |
+---------------------------------------------------+
| SYSTEM Step 1 patches | Step 8 SUID |
| Step 9 kernel sysctl |
+---------------------------------------------------+
| EVIDENCE Step 10 journal | Step 11 auditd |
| Step 12 Lynis audit |
+---------------------------------------------------+
Ports: 22/tcp SSH | 80, 443/tcp web | everything else closed
Step 0 - Two Rules Before You Touch Anything
Rule one: open a second SSH session and leave it alone until you finish. Rule two: check before you change. Then cache your sudo password so pasted commands don't get eaten by the password prompt.
Same on Ubuntu/Debian and RHEL/Rocky Linux:
LinuxTeck.com
who
Expected output:
Two pts lines for your user means both sessions are live. If you only see one, open another terminal now and log in. The spare session stays logged in even after you restart sshd, which is exactly why it saves you.
Step 1 - Patch It, Then Actually Reboot
Updates you installed but never booted into don't protect you. The kernel fix sits on disk while the old kernel keeps running.
On Ubuntu/Debian:
LinuxTeck.com
apt list --upgradable
uname -r
ls /boot | grep vmlinuz
Expected output:
The running kernel is 6.8.0-85 but 6.8.0-87 is already installed. That gap is the problem. Install everything pending and check the reboot flag:
LinuxTeck.com
ls /var/run/reboot-required
Expected output:
If the file exists, a reboot is pending. Read the output first, then reboot as a separate command:
LinuxTeck.com
Expected output:
After the box comes back, uname -r should print the newest version from /boot. Not every update needs a reboot though. When OpenSSL or glibc gets patched, the services using them keep running the old code until they restart. Ubuntu and Debian ship needrestart for exactly that:
LinuxTeck.com
Expected output:
The -r l flag only lists, nothing gets restarted. Run the listed commands in a quiet window. If you get needrestart: command not found, install it with sudo apt install -y needrestart.
On RHEL/Rocky Linux:
LinuxTeck.com
uname -r
sudo dnf needs-restarting -r
Expected output:
If you see dnf: No such command: needs-restarting, install the plugin package with sudo dnf install -y dnf-plugins-core and run it again. On RHEL and Rocky 9 / 10, that package is what provides needs-restarting.
Now make patching automatic. Ubuntu ships unattended-upgrades but keeps automatic reboot off by default, so kernel fixes can sit unbooted for months. Patched on paper, open in practice.
On Ubuntu/Debian:
LinuxTeck.com
grep -n Automatic-Reboot /etc/apt/apt.conf.d/50unattended-upgrades
Expected output:
Uncomment both lines and set the first to "true" if a 2 AM reboot is fine for this server. If it isn't, put a monthly reboot window in your calendar instead. Either way, decide on purpose.
On RHEL/Rocky Linux:
LinuxTeck.com
sudo sed -i 's/^apply_updates.*/apply_updates = yes/' /etc/dnf/automatic.conf
sudo systemctl enable --now dnf-automatic.timer
Expected output:
The timer now downloads and applies updates daily. It does not reboot, so needs-restarting -r is still your weekly check.
Step 2 - Find Out Who Really Has Root
Root isn't just the account named root. Any user with UID 0 is root, and any user with broad sudo rules is root with an extra step.
Same on Ubuntu/Debian and RHEL/Rocky Linux:
LinuxTeck.com
sudo -l
awk -F: '$3 == 0 {print $1}' /etc/passwd
Expected output:
The last line must be only root. A second name there is a second root account, and you need to find out who created it and why. On RHEL and Rocky the admin group is wheel (GID 10) instead of sudo, same idea. If you need to tighten who gets what, our guide on how to configure sudo in Linux walks through per-command rules.
While you're here, check for accounts with no password at all and confirm the root password is locked:
LinuxTeck.com
sudo passwd -S root
Expected output:
No output from the first command is the good result. The L in the second line means root's password is locked, which is the Ubuntu default. On Rocky the format is longer, something like root PS 2026-05-02 0 99999 7 -1 (Password set, SHA512 crypt.). Since root login over SSH is getting turned off in the next step anyway, sudo passwd -l root is optional but tidy.
One more, and it's the one people argue about. Passwordless sudo turns any stolen user session into instant root. Cloud images love to add it for the default user:
LinuxTeck.com
Expected output:
Nothing printed is the goal on a human account. If you see a line like this, set a real password for that user, then edit the file with sudo visudo -f /etc/sudoers.d/90-cloud-init-users and drop the NOPASSWD: part. Keep your other session open while you test. A service account for automation is a different case, see the Ansible question in the FAQ. Same rule for databases, by the way: no database account without a password, and no shared password reused across servers.
Step 3 - SSH With Keys Only, and No Root Login
Password guessing is what almost every bot on port 22 does. Take passwords away and they have nothing to guess. Do this from your laptop first, not the server.
On your laptop (any distro, macOS too):
LinuxTeck.com
ssh-copy-id admin@203.0.113.10
Expected output:
Log in once with the key to make sure it works before going further. If you want more background on keys and agents, see our walkthrough on passwordless SSH login, and if OpenSSH isn't even installed yet, start with how to install and secure an SSH server. Now look at what the server is actually running with. Use sshd -T, it prints the effective config after every include file is merged, which is what really matters. Every keyword is documented in the official sshd_config man page.
Same on Ubuntu/Debian and RHEL/Rocky Linux:
LinuxTeck.com
Expected output:
without-password is how sshd prints the default prohibit-password. Password login is still on. Lock it down with a small drop-in file of your own instead of editing the main config, it survives package upgrades and is easy to undo:
LinuxTeck.com
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
MaxAuthTries 3
X11Forwarding no
EOF
sudo sshd -t && echo "config OK"
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication)'
Expected output:
All three now say no. Apply it with a reload, which keeps existing sessions alive:
On Ubuntu/Debian:
LinuxTeck.com
Expected output:
On a fresh Debian 13 install, if you see Job for ssh.service failed because the control process exited with error code and the journal says fatal: Cannot bind any address, that's a known reload issue when ssh.socket is active. Use sudo systemctl restart ssh instead, existing sessions stay connected.
On RHEL/Rocky Linux:
LinuxTeck.com
Expected output:
One more line most guides skip: tell sshd exactly who may log in. Create a group, put your admin user in it first, then allow only that group:
Same on Ubuntu/Debian and RHEL/Rocky Linux:
LinuxTeck.com
sudo usermod -aG sshusers admin
id admin
echo "AllowGroups sshusers" | sudo tee -a /etc/ssh/sshd_config.d/10-hardening.conf
sudo sshd -t && sudo sshd -T | grep allowgroups
Expected output:
Check that sshusers shows up in the id line before you reload, then reload again (ssh on Ubuntu/Debian, sshd on RHEL/Rocky). Now a forgotten test or deploy account with a weak password can't log in over SSH at all, even if someone set one.
Prove it from your laptop by forcing a password attempt. You want it refused:
LinuxTeck.com
Expected output:
Only publickey is listed as an allowed method. That's the whole point. If sshd -T complains about missing host keys, run sudo ssh-keygen -A once. If it still shows passwordauthentication yes after the change, jump to Issue 02 below, a cloud-init file is almost certainly overriding you.
PasswordAuthentication alone isn't enough either, KbdInteractiveAuthentication can still pop a password prompt through PAM. A lot of guides forget that second line, and a lot of servers are still guessable because of it.
Step 4 - Leave the Ciphers Alone
This one is a do-nothing step, and it's on the list because so many old guides tell you to do the opposite.
On Ubuntu/Debian:
LinuxTeck.com
ssh -Q kex | grep mlkem
ssh -V
Expected output:
That's Debian 13 with OpenSSH 10.0, which made the post-quantum mlkem768x25519-sha256 key exchange the default. Ubuntu 26.04 ships OpenSSH 10.2 and gets the same default. Ubuntu 24.04 ships OpenSSH 9.6, so the mlkem grep prints nothing there, and that's fine, the defaults are still modern. For an outside view, run the open source ssh-audit tool from your laptop against the server, it grades what the server offers without touching any config.
Here's the trap. A server with a KexAlgorithms line copied from a 2016 blog post never picks up new defaults again. Hardening that quietly turns into a weakness. If a compliance rule says drop one algorithm, use the minus form, for example Ciphers -aes128-ctr, which removes one item and keeps the rest of the default set.
On RHEL/Rocky Linux:
LinuxTeck.com
sudo sshd -T | grep -E '^ciphers'
Expected output:
RHEL-family systems drive SSH, TLS and Kerberos crypto from one system-wide policy. Don't put cipher lines in sshd_config here. If you need stricter settings, run sudo update-crypto-policies --set FUTURE and reboot, then test older clients before you roll it out.
Step 5 - Let SSH Ban Brute Forcers by Itself
Since OpenSSH 9.8, sshd can penalize source addresses that keep failing to log in, without any extra tool or log parsing.
Same check on Ubuntu/Debian and RHEL/Rocky Linux:
LinuxTeck.com
sudo sshd -T | grep -i persource
Expected output:
If you see a persourcepenalties line, it's on by default and you probably don't need fail2ban just for SSH. Debian 13 (10.0), Ubuntu 26.04 (10.2), RHEL and Rocky 10 (9.9) and RHEL and Rocky 9.8 (which moved from 8.7 to OpenSSH 9.9) all have it. Ubuntu 24.04 (9.6) and Rocky or RHEL 9.7 and older (8.7) don't, the grep comes back empty. The upstream OpenSSH release notes list exactly which version added what. For the older ones, install fail2ban:
On Ubuntu/Debian:
LinuxTeck.com
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[sshd]
enabled = true
backend = systemd
maxretry = 5
bantime = 1h
EOF
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
Expected output:
Banned IPs showing up within minutes is normal. If the jail shows Total failed: 0 for hours on a public box, the backend isn't reading your logs. Debian 12 and newer don't install rsyslog, so backend = systemd matters there.
On RHEL/Rocky Linux:
LinuxTeck.com
sudo dnf install -y fail2ban fail2ban-firewalld
sudo systemctl enable --now fail2ban
sudo fail2ban-client status
Expected output:
Create the same jail.local as above first, otherwise the jail list will be empty. Fail2ban is still worth having for web and mail logins even when sshd handles itself.
Step 6 - Firewall: Block Everything, Then Allow What You Need
Default deny incoming, then open only the ports your server actually serves. Allow SSH before you enable anything on a remote host.
On Ubuntu/Debian:
LinuxTeck.com
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit 22/tcp
sudo ufw --force enable
sudo ufw status verbose
Expected output:
limit instead of allow adds rate limiting, 6 connections in 30 seconds from one IP gets denied. Add your app ports with sudo ufw allow 443/tcp. ufw doesn't filter anything itself, it writes netfilter rules for you, which you can see with sudo nft list ruleset. Running Docker? Read Issue 01 before you trust this output.
On RHEL/Rocky Linux:
LinuxTeck.com
sudo firewall-cmd --get-default-zone
sudo firewall-cmd --permanent --remove-service=cockpit
sudo firewall-cmd --permanent --remove-service=dhcpv6-client
sudo firewall-cmd --reload
sudo firewall-cmd --list-all
Expected output:
Rocky and RHEL open cockpit (port 9090) and dhcpv6-client in the public zone out of the box. If services shows only ssh, you're good. If you get FirewallD is not running, the enable command failed, check journalctl -u firewalld. More zone and rich rule examples are in our firewall-cmd commands guide.
Step 7 - Close the Doors You Forgot Were Open
A firewall hides a service. Turning it off removes it. Do both.
Same on Ubuntu/Debian and RHEL/Rocky Linux:
LinuxTeck.com
Expected output:
Look at the address column first. 127.0.0.1 means only this machine can reach it, MySQL is fine. 0.0.0.0 means the whole network. rpcbind on port 111 is the classic forgotten one, it's pulled in by NFS packages and nobody uses it on a web server. If you want to dig deeper into the filters, our ss command guide covers them.
LinuxTeck.com
sudo systemctl mask rpcbind.service rpcbind.socket
Expected output:
Disable the socket too, or systemd will happily start the service again the moment something knocks. mask stops other packages from pulling it back in. On Rocky also look for cockpit.socket, and on anything that started life as a desktop install, cups and avahi-daemon. Run systemctl list-units --type=service --state=running and question every line you don't recognize.
For the services you keep, systemd can score how exposed each one is:
LinuxTeck.com
Expected output:
High numbers are normal for most packaged services, so don't panic. Use it on your own app's unit: adding ProtectSystem=strict, PrivateTmp=yes and NoNewPrivileges=yes under [Service] can drop a score from 9 to around 5. Then check that /tmp can't be used to launch dropped binaries:
LinuxTeck.com
Expected output:
You want nosuid,nodev,noexec in there. No output means /tmp isn't a separate mount, and on Ubuntu you can fix that with sudo systemctl enable --now tmp.mount plus a drop-in setting Options=mode=1777,strictatime,nosuid,nodev,noexec. Test package installs afterwards, a few installer scripts expect to run from /tmp.
Step 8 - Hunt Down SUID Programs
SUID binaries run as their owner, usually root, no matter who launches them. It's one of the first things anyone checks after getting a low privilege shell on your box, so check it first.
Same on Ubuntu/Debian and RHEL/Rocky Linux:
LinuxTeck.com
Expected output:
That's a clean minimal Ubuntu server, about 12 entries. passwd and sudo need the bit. A box that started life as a workstation install often shows things like ntfs-3g, vmware-user-suid-wrapper and a whole set of wireless capture helpers in that list. None of them belong on a server. -xdev keeps find on the root filesystem so it doesn't crawl NFS mounts for ten minutes.
On Ubuntu/Debian (survives package updates):
LinuxTeck.com
ls -l /usr/bin/ntfs-3g
Expected output:
The s in -rwsr-xr-x is gone. A plain chmod u-s works too but the next package update puts the bit back, dpkg-statoverride makes dpkg remember your choice.
On RHEL/Rocky Linux:
LinuxTeck.com
sudo chmod u-s /usr/bin/staprun
Expected output:
RPM has no override database, so a package update can restore the bit. Re-run the find after updates, or just remove the package if you don't need it. For how SUID, SGID and the sticky bit really work, see our special permissions in Linux guide.
Step 9 - Tighten the Kernel With One File
A handful of sysctl values hide kernel addresses, restrict kernel logs, and stop the network stack from being redirected. Check the current values first.
Same on Ubuntu/Debian and RHEL/Rocky Linux:
LinuxTeck.com
Expected output:
That's from a Debian-based rolling install, and it's a good example of why you check. Ubuntu already sets kptr_restrict = 1 and dmesg_restrict = 1. Others leave them at 0. Write your own file so it survives reboots and upgrades:
LinuxTeck.com
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.log_martians = 1
EOF
# IPv6 line only if IPv6 is enabled, avoids "cannot stat" errors
[ -d /proc/sys/net/ipv6 ] && echo "net.ipv6.conf.all.accept_redirects = 0" | sudo tee /etc/sysctl.d/99-hardening-ipv6.conf
sudo sysctl --system | tail -6
Expected output:
kptr_restrict = 2 doesn't fix any bug by itself. It just takes kernel memory addresses away from non-root users, which makes a lot of exploit chains much harder to build. The IPv6 setting lives in its own file and only gets written when /proc/sys/net/ipv6 exists. On minimal servers booted with ipv6.disable=1 that folder is missing, so the line is skipped and sysctl --system stays free of cannot stat /proc/sys/net/ipv6/... errors. The first output line is that tee echoing the setting back. No line there means IPv6 is off, which is fine. The last line of your output can differ by distro: Ubuntu still ships /etc/sysctl.conf, so it ends with * Applying /etc/sysctl.conf ..., while Debian 13 dropped that file. Hosts with two NICs, VPN gateways and Kubernetes nodes may need rp_filter = 2 (loose), see Issue 04.
Step 10 - Make Sure Your Logs Survive the Reboot
Step 1 told you to reboot. If your journal lives in RAM, that reboot also wiped every trace of what happened before it.
Same on Ubuntu/Debian and RHEL/Rocky Linux:
LinuxTeck.com
journalctl --list-boots | tail -3
journalctl --disk-usage
Expected output:
The directory exists and older boots are listed, so logs are persistent. If you get No such file or directory and only boot 0, your logs vanish on every reboot. Fix it and cap the size so the journal can't fill your disk:
LinuxTeck.com
sudo tee /etc/systemd/journald.conf.d/50-persist.conf > /dev/null <<'EOF'
[Journal]
Storage=persistent
SystemMaxUse=1G
EOF
sudo systemctl restart systemd-journald
journalctl --disk-usage
Expected output:
Now the fun part. Let the logs show you the bots from the intro. The SSH unit is ssh on Ubuntu and Debian, sshd on RHEL and Rocky:
LinuxTeck.com
Expected output:
Anyone with root can wipe local logs, so on anything that matters, forward them to another machine. Our Linux logging best practices guide covers rsyslog and journald forwarding.
Logs are only evidence if the clock is right. A server that drifted 20 minutes makes every timeline wrong:
LinuxTeck.com
Expected output:
Both should say yes/active. On RHEL and Rocky, chronyc tracking shows the exact offset. If NTP is inactive, run sudo timedatectl set-ntp true.
Step 11 - Set a Tripwire on the Important Files
auditd records who touched what, when, and with which program. It's the answer to "who added that user?" six weeks from now.
On Ubuntu/Debian:
LinuxTeck.com
sudo systemctl enable --now auditd
Expected output:
On RHEL/Rocky Linux:
LinuxTeck.com
Expected output:
RHEL and Rocky install and run auditd by default. Now add a watch, trigger it yourself, and read the alarm:
LinuxTeck.com
sudo touch /etc/passwd
sudo ausearch -k passwd_watch -i | tail -3
Expected output:
See auid=admin? That's the login user, even though the command ran as root through sudo. If the search returns <no matches>, your rule probably got flushed, read Issue 03. Rules added with auditctl also disappear at reboot, so make them permanent:
LinuxTeck.com
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers
-w /etc/ssh/sshd_config -p wa -k sshd
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
EOF
sudo augenrules --load
sudo auditctl -l
Expected output:
The Old style watch rules are slower line is a hint, not an error. For a few files it doesn't matter. On RHEL, systemctl restart auditd is refused on purpose, use augenrules --load or service auditd restart instead. And remember, auditd only records. It doesn't block anything.
While you're in the audit tools, answer the question every beginner asks: has anyone actually logged in?
LinuxTeck.com
sudo lastb -n 3
sudo aureport -au --summary | head -8
Expected output:
last shows good logins, lastb shows failed ones. Every successful login should be someone you know. On RHEL and Rocky, sudo faillock --user admin shows whether PAM has temporarily locked an account after too many bad passwords.
Step 12 - Get a Second Opinion From Lynis
Lynis runs a few hundred local checks and gives you warnings, suggestions and a hardening index. Free, no agent, nothing leaves the box.
On Ubuntu/Debian:
LinuxTeck.com
sudo lynis audit system --quick
Expected output:
On RHEL/Rocky Linux:
LinuxTeck.com
sudo dnf install -y lynis
sudo lynis audit system --quick
Expected output:
Lynis lives in EPEL on the RHEL side. Skip the scrolling and pull just the important parts from the report file:
LinuxTeck.com
Expected output:
Warnings first, then suggestions. Fix what fits your server and skip the rest. Don't chase the score, people who push it past 90 usually end up breaking something their own apps needed. Lynis is an auditor, not an intrusion detector, so pair it with the detection tools covered further down.
One Script That Proves All 12 Moves
Lots of people search for a Linux server hardening script they can just run. I'd rather you run a script that checks, not one that changes things you haven't read. Save this as harden-check.sh, run it after the steps above, and again every month.
LinuxTeck.com
# harden-check.sh : run with sudo, read-only checks
pass=0; fail=0
ok() { echo "[ PASS ] $1"; pass=$((pass+1)); }
bad() { echo "[ FAIL ] $1"; fail=$((fail+1)); }
SSHD=$(sshd -T 2>/dev/null)
# 1. Newest installed kernel is the running one
newest=$(ls /boot/vmlinuz-* | sort -V | tail -1 | sed 's|/boot/vmlinuz-||')
[ "$newest" = "$(uname -r)" ] && ok "Kernel $(uname -r) is live" || bad "Reboot pending, newest is $newest"
# 2. Only root has UID 0
[ "$(awk -F: '$3==0' /etc/passwd | wc -l)" -eq 1 ] && ok "Single UID 0 account" || bad "Extra UID 0 accounts"
grep -rqs NOPASSWD /etc/sudoers /etc/sudoers.d/ && bad "NOPASSWD sudo rule found" || ok "No passwordless sudo"
# 3. SSH keys only, no root
echo "$SSHD" | grep -q '^passwordauthentication no' && ok "PasswordAuthentication no" || bad "Password auth on"
echo "$SSHD" | grep -q '^kbdinteractiveauthentication no' && ok "KbdInteractiveAuthentication no" || bad "Keyboard-interactive on"
echo "$SSHD" | grep -q '^permitrootlogin no' && ok "PermitRootLogin no" || bad "Root login allowed"
# 4. No hardcoded crypto lists
grep -rqiE '^\s*(ciphers|kexalgorithms)\s' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ && bad "Hardcoded Ciphers/Kex line" || ok "SSH crypto on defaults"
# 5. Brute force protection
if echo "$SSHD" | grep -q '^persourcepenalties'; then ok "PerSourcePenalties on"
elif systemctl is-active -q fail2ban; then ok "fail2ban active"
else bad "No brute force protection"; fi
# 6. Firewall
if command -v ufw >/dev/null; then ufw status | grep -q 'Status: active' && ok "ufw active" || bad "ufw inactive"
else firewall-cmd --state 2>/dev/null | grep -q running && ok "firewalld running" || bad "No firewall"; fi
# 7 and 8. Things to review by eye
echo "[ INFO ] $(ss -tulnH | awk '{print $5}' | grep -Evc '^(127\.|\[::1\])') sockets on non-loopback addresses"
echo "[ INFO ] $(find / -xdev -perm -4000 -type f 2>/dev/null | wc -l) SUID binaries"
# 9. Kernel
[ "$(sysctl -n kernel.kptr_restrict)" -ge 1 ] && ok "kptr_restrict set" || bad "kptr_restrict is 0"
[ "$(sysctl -n kernel.dmesg_restrict)" -eq 1 ] && ok "dmesg_restrict set" || bad "dmesg_restrict is 0"
# 10. Persistent journal
[ -d /var/log/journal ] && ok "Journal is persistent" || bad "Journal lives in RAM"
# 11. Audit tripwire
auditctl -l 2>/dev/null | grep -q '/etc/passwd' && ok "auditd watching /etc/passwd" || bad "No audit watch"
# 12. Lynis present
command -v lynis >/dev/null && ok "Lynis installed" || bad "Lynis missing"
echo "----"
echo "PASS: $pass FAIL: $fail"
# Machine-readable summary for monitoring: ./harden-check.sh --json
[ "$1" = "--json" ] && echo "{\"host\":\"$(hostname)\",\"pass\":$pass,\"fail\":$fail}"
[ "$fail" -eq 0 ]
Make it executable and run it:
LinuxTeck.com
sudo ./harden-check.sh
Expected output:
One FAIL here means Step 10 was skipped on this box. The script exits non-zero when anything fails, so you can drop it into cron, a systemd timer or a CI job and alert on the exit code. It works the same on Ubuntu, Debian, Rocky and RHEL.
Got more than a handful of servers? Push the same script to all of them with an Ansible ad hoc command from your control node. It copies, runs and reports, nothing is installed:
LinuxTeck.com
Expected output:
Any host with rc=1 needs attention. Feed the JSON line into whatever monitoring you already run and you get a hardening dashboard for free.
Production Pitfalls and Fixes
These are the ones that bite people after they think they're done.
Docker published ports ignore ufw
Environment: Ubuntu 24.04 with ufw default deny and Docker CE running a container with -p 8080:80.
Failure pattern: ufw shows no rule for 8080, yet the port is reachable from the internet. Docker writes its own iptables rules that are evaluated before ufw's chains.
LinuxTeck.com
sudo ss -tlnp | grep 8080
Expected output:
Nothing from ufw, but docker-proxy listens on 0.0.0.0. Publish on loopback instead, -p 127.0.0.1:8080:80, and put a reverse proxy on 443 in front of it.
cloud-init quietly turns password login back on
Environment: Ubuntu cloud images on most VPS providers.
Failure pattern: You set PasswordAuthentication no in /etc/ssh/sshd_config but sshd -T still says yes. sshd uses the first value it reads, and the Include line for the drop-in folder sits at the top of the main file.
LinuxTeck.com
Expected output:
That is why Step 3 names the file 10-hardening.conf. It sorts before 50-cloud-init.conf and 50-redhat.conf, so your values win.
Your first audit rule disappears right after install
Environment: Fresh auditd install on a Debian-based system, watch added a second after systemctl enable --now auditd.
Failure pattern: The audit-rules service loads /etc/audit/audit.rules on startup, which begins with -D (delete all rules). If it finishes after your auditctl command, your watch is gone.
LinuxTeck.com
Expected output:
op=remove_rule key=passwd_watch is the proof. Put rules in /etc/audit/rules.d/ and load them with augenrules --load, never rely on a one-off auditctl line.
Strict reverse path filtering drops real traffic
Environment: Hosts with two NICs, WireGuard gateways, Kubernetes or Docker Swarm nodes.
Failure pattern: After Step 9, replies on the second interface or tunnel stop arriving and dmesg shows martian source messages. rp_filter = 1 drops packets that come in on a different interface than the route back would use.
LinuxTeck.com
sudo sed -i 's/^net.ipv4.conf.all.rp_filter.*/net.ipv4.conf.all.rp_filter = 2/' /etc/sysctl.d/99-hardening.conf
Expected output:
Loose mode (2) still drops spoofed sources that have no route at all, it just tolerates asymmetric paths.
Quick Reference: The 12 Moves in One Table
Save this, print it, stick it next to your runbook. Every row has the command that proves it.
| # | Move | What You Get | Prove It |
|---|---|---|---|
| 1 | Patch and reboot | Kernel fix actually live | uname -r |
| 2 | Only one root | No hidden UID 0 accounts | awk -F: '$3==0' /etc/passwd |
| 3 | SSH keys only | No passwords, no root | sshd -T |
| 4 | Leave ciphers alone | Modern defaults stay current | ssh -Q kex |
| 5 | Built-in brute force ban | sshd blocks repeat offenders | sshd -T | grep persource |
| 6 | Default deny firewall | Only chosen ports open | ufw status verbose / firewall-cmd --list-all |
| 7 | Close unused ports | Nothing listening by surprise | ss -tulnp |
| 8 | Hunt SUID programs | Fewer paths to root | find / -xdev -perm -4000 |
| 9 | Kernel hardening | Hidden addresses, safer network | sysctl --system |
| 10 | Logs that survive | Evidence kept after reboot | journalctl --list-boots |
| 11 | File tripwires | Know who changed what | ausearch -k identity |
| 12 | Lynis audit | Second opinion | lynis audit system |
What This Will Not Protect You From, and Linux Hardening Tools to Add Next
Be honest with yourself here. These 12 moves close the doors that bots rattle automatically. They do nothing about a bug in your own web app, a stolen laptop with an unlocked SSH key, a poisoned package, or a zero-day nobody has patched yet. Most real breaches walk in through the app on port 443, not through SSH.
That's not a reason to skip the baseline. It's a reason to finish it fast and then work outward. When we asked the LinuxTeck community what they never skip, these came up again and again, so here they are, roughly in the order I'd do them.
Keep SELinux or AppArmor in Enforcing Mode
Detection tells you something happened. Prevention stops it. Mandatory access control is the prevention layer you already have installed, it limits what a hijacked nginx or PHP process can touch even when it runs as a service user. Check it:
On RHEL/Rocky Linux:
LinuxTeck.com
Expected output:
On Ubuntu/Debian:
LinuxTeck.com
Expected output:
If RHEL says Permissive or Disabled, somebody turned it off to make an install work. Find out why and fix the policy instead. Our SELinux vs AppArmor comparison explains how each one behaves day to day.
Add Intrusion Detection on the Host
auditd from Step 11 watches a few files. A file integrity checker like AIDE takes a hash of every system binary and config, then tells you what changed since. It's quiet, free, and in the default repos.
On Ubuntu/Debian:
LinuxTeck.com
sudo aideinit
sudo aide --check --config /etc/aide/aide.conf | head -6
Expected output:
On RHEL/Rocky Linux:
LinuxTeck.com
sudo aide --init
sudo mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz
sudo aide --check | head -3
Expected output:
Run the check daily from a timer and mail yourself the output. Copy the database off the box too, an attacker with root can rebuild it otherwise. For bigger fleets, a host IDS such as Wazuh and a network IDS/IPS such as Suricata give you central alerts instead of one email per server.
MFA on SSH and sudo
Keys stop password guessing. They don't stop someone who copied your ~/.ssh folder. The cleanest fix is a hardware-backed key, the private half never leaves the security key:
LinuxTeck.com
Expected output:
Every login now needs a physical touch. If hardware keys feel like too much hassle (plenty of people say so), a TOTP app through libpam-google-authenticator on Ubuntu or google-authenticator from EPEL on Rocky works too. That route needs AuthenticationMethods publickey,keyboard-interactive and KbdInteractiveAuthentication yes in your drop-in, so it overrides one line from Step 3 on purpose. Add the same PAM module to /etc/pam.d/sudo if you want sudo to ask for the code as well.
Reverse Proxy, WAF, Modern TLS and Rate Limiting on 443
Port 443 is the one door you can't close, so put something in front of the app. A reverse proxy (nginx or HAProxy) terminates TLS, rate limits, and filters junk before it reaches PHP or Node. HAProxy ACLs can block a lot of obvious SQL injection and scanner paths, and nginx with ModSecurity plus the OWASP Core Rule Set gives you a real WAF. A minimal nginx starting point:
LinuxTeck.com
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
server_tokens off;
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
EOF
sudo nginx -t && sudo systemctl reload nginx
Expected output:
Then add limit_req zone=perip burst=20 nodelay; inside the location blocks that take logins or API calls. TLS 1.0 and 1.1 have no business being on in 2026, "who cares" is how they stay on. Prove what your server really offers from another machine. nmap isn't on minimal installs, so add it first with sudo apt install -y nmap or sudo dnf install -y nmap:
LinuxTeck.com
Expected output:
Only TLSv1.2 and TLSv1.3 listed is what you want. An online SSL checker gives you the same picture from outside your network, run one after every certificate or proxy change.
Backups You Have Actually Restored
Assume one day a box gets owned anyway. What saves you then is recovery: good backups, read-only snapshots an attacker can't delete, and the ability to rebuild the server from scratch. "Good" means verified, a backup job that has been writing empty or corrupt archives for six months looks exactly like a working one until you need it.
LinuxTeck.com
Expected output:
Then do a real test restore to a scratch VM every quarter. Our guide to Linux server backup solutions compares the main options and how to keep one copy offline. If ransomware is the worry, our notes on Linux ransomware protection cover immutable and offline copies in more detail.
The Non-Technical Ones
- Separate dev and ops access. Developers deploy through a pipeline, they don't get a root shell on production. Least privilege applies to people too.
- Segment the network. The web server shouldn't be able to reach the HR file share. A compromised site should hit a wall, not your whole internal network.
- Control egress on sensitive servers. Default allow outgoing is fine for most boxes, but on a database host it lets anyone set up their own tunnel or VPN out. Lock outgoing to what the server needs.
- Bind databases to localhost or a private network. And never with an empty or shared password. The
sscheck from Step 7 catches the first part. - Consider an immutable OS for single-purpose hosts. Fedora CoreOS, Flatcar or an image-based RHEL setup keep
/usrread-only, so there's very little an attacker can quietly change. - Think like an attacker. Once a quarter, try to break in from a fresh low privilege account. Whatever you find first, they would have found first.
Undo Everything (Optional)
Ran this on a test VM and want it back the way it was? Every change lives in its own file, so rolling back is quick.
On Ubuntu/Debian:
LinuxTeck.com
sudo rm -f /etc/audit/rules.d/50-identity.rules /etc/fail2ban/jail.local
sudo sysctl --system > /dev/null
sudo augenrules --load
sudo systemctl reload ssh
sudo ufw disable
Expected output:
On RHEL/Rocky Linux:
LinuxTeck.com
sudo rm -f /etc/audit/rules.d/50-identity.rules
sudo sysctl --system > /dev/null
sudo augenrules --load
sudo systemctl reload sshd
sudo firewall-cmd --permanent --add-service=cockpit && sudo firewall-cmd --reload
Expected output:
Unmask anything you masked in Step 7 with sudo systemctl unmask rpcbind.service rpcbind.socket. A sysctl value that was already loaded stays live until reboot, so reboot the test box to be sure.
Questions I Get Asked About This All the Time
Should I move SSH off port 22?
It cuts the log noise a lot, and that's all it does. A targeted scan finds the new port in seconds. Do it if the quiet logs help you spot real problems, but keys only and no root login are what actually stop the bots. On RHEL and Rocky remember SELinux: sudo semanage port -a -t ssh_port_t -p tcp 2222 or sshd won't start on the new port. On Ubuntu 22.10 and newer, SSH starts from ssh.socket, so after changing Port run sudo systemctl daemon-reload and sudo systemctl restart ssh.socket, and open the new port in ufw first.
Do I still need fail2ban in 2026?
For SSH only, not if sshd -T | grep persourcepenalties prints something (OpenSSH 9.8 and newer). For Nginx, Postfix, Dovecot or a WordPress login, yes, it's still the easy answer.
Is there a hardening script I can just run and be done?
There are plenty of hardening scripts and Ansible roles on GitHub. Read them before running anything as root, some of them set things like rp_filter = 1 or hardcoded cipher lists that cause exactly the problems above. Use the check script above (One Script That Proves All 12 Moves) to see where you stand, then change things deliberately.
Will any of this break Docker or Kubernetes?
Two things to watch. Docker bypasses ufw (Issue 01), and strict rp_filter can break overlay networks (Issue 04). Don't add net.ipv4.ip_forward = 0 on container hosts either, they need forwarding.
I just locked myself out. Now what?
Use the session you kept open in Step 0 and delete /etc/ssh/sshd_config.d/10-hardening.conf. No session left? Log in through your provider's web console or rescue mode, fix the file, and reload sshd. If the error makes no sense, our SSH troubleshooting guide walks through the usual causes one by one.
How often should I re-check a server?
Run the check script weekly from a timer, Lynis monthly, and the full 12 steps on every new server before it gets a public IP. Re-run the SUID search after big package updates.
Ansible needs root. Passwordless sudo or a vault password, which is worse?
Neither is great, but you can make both small. Keep secrets, including the become password, in ansible-vault. Yes, it feels like turtles all the way down because now you guard the vault password, but that's one secret in one place instead of many on many servers. Feed it from your password manager or CI secret store with --vault-password-file pointing at a small script, never a plain text file in the repo. If you do keep NOPASSWD for an automation user, give it its own key, allow it only from your control node with from= in authorized_keys, and log every command it runs.
Are hardware security keys worth the hassle over an authenticator app?
For admins with root on production, yes. TOTP codes can be phished and the seed can be copied, a FIDO2 key can't be cloned and needs a physical touch. For a home lab, an authenticator app is a fine trade off. Whatever you pick, buy two keys and register both, losing your only one is its own outage.
Is a read-only OS really more secure?
For single-purpose servers, mostly yes. With /usr read-only, dropped binaries and modified system files don't survive a reboot, and updates arrive as whole images you can roll back. The cost is that ad hoc fixes get harder, which on production is often a good thing.
Is there a Linux hardening guide PDF for audits?
For compliance work, the CIS Benchmarks for Ubuntu and RHEL are free PDF downloads and are what most auditors ask for. On RHEL and Rocky, sudo dnf install -y openscap-scanner scap-security-guide lets you scan locally, for example sudo oscap xccdf eval --profile cis_server_l1 --report report.html /usr/share/xml/scap/ssg/content/ssg-rl10-ds.xml on Rocky 10 (use ssg-rhel10-ds.xml on RHEL 10), then open report.html in a browser.
Conclusion: Check It, Lock It, Prove It
Hardening isn't about how many lines you can stuff into config files. It's knowing what the server runs, closing what doesn't need to be open, and proving each change with a command. Twelve moves, one reboot, done.
Your next action: run harden-check.sh on one server you own right now, before anything else. Fix whatever shows FAIL, then do the next box.
Which of these 12 was missing on your server? Tell us in the comments. If this article helped, share it with the person on your team who still logs in as root.
A 12 step Linux server hardening checklist for Ubuntu, Debian, RHEL and Rocky Linux, with a proof command and a read-only check script for every move.
LinuxTeck's Enterprise Linux category focuses on production-ready Linux skills including:
Linux server hardening, SSH security, firewall configuration,
auditd monitoring, kernel sysctl tuning, and Linux security auditing.