12 Linux Server Hardening Like a Pro

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.

What You Need:

  • 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.
Warning:

If you skip the console access check and then make a mistake in Step 3 or Step 6, you can lose remote access completely. On a bare metal box with no IPMI or KVM, that means a trip to the data center or a support ticket.
Quick Glossary:

Root / UID 0: the all-powerful admin account, any user with user ID 0 is root. sudo: runs one command as root. SUID: a file permission that makes a program run as its owner (often root) no matter who starts it. sysctl: live kernel settings you can read and change. PAM: the login plumbing that decides how users authenticate. SELinux / AppArmor: mandatory access control (MAC), rules that limit what each program may touch. Heredoc (<<'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
Concept:

Each layer assumes the one above it will fail one day. A firewall mistake shouldn't hand out a shell, and a stolen shell should still leave tracks in the journal and the audit log.

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:

bash
LinuxTeck.com
sudo -v
who

Expected output:

OUTPUT
admin pts/0 2026-10-25 09:12 (198.51.100.20)
admin pts/1 2026-10-25 09:13 (198.51.100.20)

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:

bash
LinuxTeck.com
sudo apt update
apt list --upgradable
uname -r
ls /boot | grep vmlinuz

Expected output:

OUTPUT
Hit:1 http://archive.ubuntu.com/ubuntu noble InRelease
Get:2 http://security.ubuntu.com/ubuntu noble-security InRelease [126 kB]
Fetched 1,846 kB in 2s (923 kB/s)
Listing... Done
libssl3t64/noble-updates,noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable from: 3.0.13-0ubuntu3.5]
openssl/noble-updates,noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable from: 3.0.13-0ubuntu3.5]
6.8.0-85-generic
vmlinuz
vmlinuz-6.8.0-85-generic
vmlinuz-6.8.0-87-generic
vmlinuz.old

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:

bash
LinuxTeck.com
sudo apt full-upgrade -y
ls /var/run/reboot-required

Expected output:

OUTPUT
/var/run/reboot-required

If the file exists, a reboot is pending. Read the output first, then reboot as a separate command:

bash
LinuxTeck.com
sudo reboot

Expected output:

OUTPUT
Connection to 203.0.113.10 closed by remote host.

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:

bash
LinuxTeck.com
sudo needrestart -r l

Expected output:

OUTPUT
Scanning processes...
Scanning linux images...

Running kernel seems to be up-to-date.

Services to be restarted:
systemctl restart nginx.service
systemctl restart ssh.service

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:

bash
LinuxTeck.com
sudo dnf upgrade -y
uname -r
sudo dnf needs-restarting -r

Expected output:

OUTPUT
Complete!
6.12.0-55.20.1.el10_0.x86_64
Core libraries or services have been updated since boot-up:
* kernel

Reboot is required to fully utilize these updates.

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:

bash
LinuxTeck.com
sudo apt install -y unattended-upgrades
grep -n Automatic-Reboot /etc/apt/apt.conf.d/50unattended-upgrades

Expected output:

OUTPUT
unattended-upgrades is already the newest version (2.9.1+nmu4ubuntu1).
134://Unattended-Upgrade::Automatic-Reboot "false";
140://Unattended-Upgrade::Automatic-Reboot-Time "02:00";

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:

bash
LinuxTeck.com
sudo dnf install -y dnf-automatic
sudo sed -i 's/^apply_updates.*/apply_updates = yes/' /etc/dnf/automatic.conf
sudo systemctl enable --now dnf-automatic.timer

Expected output:

OUTPUT
Created symlink '/etc/systemd/system/timers.target.wants/dnf-automatic.timer' → '/usr/lib/systemd/system/dnf-automatic.timer'.

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:

bash
LinuxTeck.com
id
sudo -l
awk -F: '$3 == 0 {print $1}' /etc/passwd

Expected output:

OUTPUT
uid=1000(admin) gid=1000(admin) groups=1000(admin),27(sudo)
Matching Defaults entries for admin on web01:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty

User admin may run the following commands on web01:
(ALL : ALL) ALL
root

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:

bash
LinuxTeck.com
sudo awk -F: '($2 == "") {print $1}' /etc/shadow
sudo passwd -S root

Expected output:

OUTPUT
root L 2026-04-23 0 99999 7 -1

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:

bash
LinuxTeck.com
sudo grep -rn NOPASSWD /etc/sudoers /etc/sudoers.d/

Expected output:

OUTPUT
/etc/sudoers.d/90-cloud-init-users:2:ubuntu ALL=(ALL) NOPASSWD:ALL

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):

bash
LinuxTeck.com
ssh-keygen -t ed25519 -C "admin@laptop"
ssh-copy-id admin@203.0.113.10

Expected output:

OUTPUT
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/admin/.ssh/id_ed25519):
Enter passphrase for "/home/admin/.ssh/id_ed25519" (empty for no passphrase):
Your identification has been saved in /home/admin/.ssh/id_ed25519
Your public key has been saved in /home/admin/.ssh/id_ed25519.pub
...
Number of key(s) added: 1

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:

bash
LinuxTeck.com
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication)'

Expected output:

OUTPUT
permitrootlogin without-password
passwordauthentication yes
kbdinteractiveauthentication no

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:

bash
LinuxTeck.com
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf > /dev/null <<'EOF'
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:

OUTPUT
config OK
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no

All three now say no. Apply it with a reload, which keeps existing sessions alive:

On Ubuntu/Debian:

bash
LinuxTeck.com
sudo systemctl reload ssh

Expected output:

OUTPUT
(no output means success)

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:

bash
LinuxTeck.com
sudo systemctl reload sshd

Expected output:

OUTPUT
(no output means success)

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:

bash
LinuxTeck.com
sudo groupadd -f sshusers
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:

OUTPUT
uid=1000(admin) gid=1000(admin) groups=1000(admin),27(sudo),1001(sshusers)
AllowGroups sshusers
allowgroups sshusers

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:

bash
LinuxTeck.com
ssh -o PubkeyAuthentication=no admin@203.0.113.10

Expected output:

OUTPUT
admin@203.0.113.10: Permission denied (publickey).

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.

Critical:

Never disable password authentication before you have logged in successfully with your key in a separate session. Turning off 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:

bash
LinuxTeck.com
sudo sshd -T | grep -E '^(ciphers|kexalgorithms)'
ssh -Q kex | grep mlkem
ssh -V

Expected output:

OUTPUT
ciphers aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr
kexalgorithms mlkem768x25519-sha256,sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521
mlkem768x25519-sha256
OpenSSH_10.0p2 Debian-7, OpenSSL 3.5.1 1 Jul 2025

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:

bash
LinuxTeck.com
update-crypto-policies --show
sudo sshd -T | grep -E '^ciphers'

Expected output:

OUTPUT
DEFAULT
ciphers aes256-gcm@openssh.com,chacha20-poly1305@openssh.com,aes256-ctr,aes128-gcm@openssh.com,aes128-ctr

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:

bash
LinuxTeck.com
ssh -V
sudo sshd -T | grep -i persource

Expected output:

OUTPUT
OpenSSH_10.0p2 Debian-7, OpenSSL 3.5.1 1 Jul 2025
persourcemaxstartups none
persourcenetblocksize 32:128
persourcepenalties crash:90 authfail:5 noauth:1 grace-exceeded:10 refuseconnection:10 max:600 min:15 max-sources4:65536 max-sources6:65536 overflow:permissive overflow6:permissive
persourcepenaltyexemptlist none

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:

bash
LinuxTeck.com
sudo apt install -y fail2ban
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:

OUTPUT
Status for the jail: sshd
|- Filter
| |- Currently failed: 2
| |- Total failed: 37
| `- Journal matches: _SYSTEMD_UNIT=ssh.service + _COMM=sshd
`- Actions
|- Currently banned: 3
|- Total banned: 9
`- Banned IP list: 198.51.100.12 192.0.2.44 203.0.113.7

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:

bash
LinuxTeck.com
sudo dnf install -y epel-release
sudo dnf install -y fail2ban fail2ban-firewalld
sudo systemctl enable --now fail2ban
sudo fail2ban-client status

Expected output:

OUTPUT
Status
|- Number of jail: 1
`- Jail list: sshd

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:

bash
LinuxTeck.com
sudo apt install -y ufw
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:

OUTPUT
Default incoming policy changed to 'deny'
(be sure to update your rules accordingly)
Default outgoing policy changed to 'allow'
(be sure to update your rules accordingly)
Rules updated
Rules updated (v6)
Firewall is active and enabled on system startup
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To Action From
-- ------ ----
22/tcp LIMIT IN Anywhere
22/tcp (v6) LIMIT IN Anywhere (v6)

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:

bash
LinuxTeck.com
sudo systemctl enable --now firewalld
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:

OUTPUT
public
success
success
success
public (default, active)
target: default
icmp-block-inversion: no
interfaces: eth0
sources:
services: ssh
ports:
protocols:
forward: yes
masquerade: no
forward-ports:
source-ports:
icmp-blocks:
rich rules:

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:

bash
LinuxTeck.com
sudo ss -tulnp

Expected output:

OUTPUT
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=612,fd=14))
tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1033,fd=3))
tcp LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=1188,fd=6))
tcp LISTEN 0 151 127.0.0.1:3306 0.0.0.0:* users:(("mysqld",pid=1102,fd=23))
tcp LISTEN 0 4096 0.0.0.0:111 0.0.0.0:* users:(("rpcbind",pid=588,fd=4))
tcp LISTEN 0 4096 [::]:22 [::]:* users:(("sshd",pid=1033,fd=4))

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.

bash
LinuxTeck.com
sudo systemctl disable --now rpcbind.service rpcbind.socket
sudo systemctl mask rpcbind.service rpcbind.socket

Expected output:

OUTPUT
Removed '/etc/systemd/system/multi-user.target.wants/rpcbind.service'.
Removed '/etc/systemd/system/sockets.target.wants/rpcbind.socket'.
Created symlink '/etc/systemd/system/rpcbind.service' → '/dev/null'.
Created symlink '/etc/systemd/system/rpcbind.socket' → '/dev/null'.

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:

bash
LinuxTeck.com
systemd-analyze security --no-pager | head -6

Expected output:

OUTPUT
UNIT EXPOSURE PREDICATE HAPPY
cron.service 9.6 UNSAFE 😨
nginx.service 9.2 UNSAFE 😨
ssh.service 9.6 UNSAFE 😨
systemd-journald.service 4.9 OK 🙂
systemd-resolved.service 2.2 OK 🙂

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:

bash
LinuxTeck.com
findmnt -no OPTIONS /tmp

Expected output:

OUTPUT
rw,nosuid,nodev,noexec,relatime,size=1012340k

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:

bash
LinuxTeck.com
find / -xdev -perm -4000 -type f 2>/dev/null

Expected output:

OUTPUT
/usr/bin/chfn
/usr/bin/su
/usr/bin/newgrp
/usr/bin/passwd
/usr/bin/gpasswd
/usr/bin/mount
/usr/bin/umount
/usr/bin/chsh
/usr/bin/sudo
/usr/bin/fusermount3
/usr/lib/openssh/ssh-keysign
/usr/lib/dbus-1.0/dbus-daemon-launch-helper

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):

bash
LinuxTeck.com
sudo dpkg-statoverride --update --add root root 0755 /usr/bin/ntfs-3g
ls -l /usr/bin/ntfs-3g

Expected output:

OUTPUT
-rwxr-xr-x 1 root root 63672 Mar 31 2024 /usr/bin/ntfs-3g

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:

bash
LinuxTeck.com
rpm -qf /usr/bin/staprun
sudo chmod u-s /usr/bin/staprun

Expected output:

OUTPUT
systemtap-runtime-5.2-2.el10.x86_64

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:

bash
LinuxTeck.com
sysctl kernel.kptr_restrict kernel.dmesg_restrict net.ipv4.tcp_syncookies net.ipv4.conf.all.accept_redirects net.ipv4.conf.all.rp_filter

Expected output:

OUTPUT
kernel.kptr_restrict = 0
kernel.dmesg_restrict = 0
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.rp_filter = 0

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:

bash
LinuxTeck.com
sudo tee /etc/sysctl.d/99-hardening.conf > /dev/null <<'EOF'
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:

OUTPUT
net.ipv6.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

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:

bash
LinuxTeck.com
ls -ld /var/log/journal
journalctl --list-boots | tail -3
journalctl --disk-usage

Expected output:

OUTPUT
drwxr-sr-x+ 3 root systemd-journal 4096 Sep 21 22:03 /var/log/journal
-2 799b64f9105e490d9b73b54f2d1812f2 Thu 2026-10-15 17:18:55 IST Thu 2026-10-15 19:11:12 IST
-1 0896565c1e0a4cc7a883180800b04f67 Fri 2026-10-16 14:13:27 IST Fri 2026-10-16 14:36:51 IST
0 d24019762f124c8e8ed8b667f6813abd Tue 2026-10-20 14:12:17 Tue 2026-10-20 14:47:02 IST
Archived and active journals take up 485.3M in the file system.

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:

bash
LinuxTeck.com
sudo mkdir -p /etc/systemd/journald.conf.d /var/log/journal
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:

OUTPUT
Archived and active journals take up 8.0M in the file system.

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:

bash
LinuxTeck.com
sudo journalctl -u ssh --since today | grep -E 'Failed|Invalid' | tail -3

Expected output:

OUTPUT
Oct 25 09:41:02 web01 sshd[4410]: Failed password for root from 203.0.113.7 port 51122 ssh2
Oct 25 09:41:09 web01 sshd[4415]: Failed password for invalid user admin from 198.51.100.12 port 40210 ssh2
Oct 25 09:41:15 web01 sshd[4419]: Invalid user oracle from 192.0.2.44 port 58830

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:

bash
LinuxTeck.com
timedatectl | grep -E 'synchronized|NTP'

Expected output:

OUTPUT
System clock synchronized: yes
NTP service: active

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:

bash
LinuxTeck.com
sudo apt install -y auditd
sudo systemctl enable --now auditd

Expected output:

OUTPUT
Setting up auditd (1:3.1.2-2.1build1.1) ...
Created symlink /etc/systemd/system/multi-user.target.wants/auditd.service → /usr/lib/systemd/system/auditd.service.

On RHEL/Rocky Linux:

bash
LinuxTeck.com
systemctl is-active auditd

Expected output:

OUTPUT
active

RHEL and Rocky install and run auditd by default. Now add a watch, trigger it yourself, and read the alarm:

bash
LinuxTeck.com
sudo auditctl -w /etc/passwd -p wa -k passwd_watch
sudo touch /etc/passwd
sudo ausearch -k passwd_watch -i | tail -3

Expected output:

OUTPUT
type=CWD msg=audit(10/25/2026 10:41:07.512:812) : cwd=/home/admin
type=PATH msg=audit(10/25/2026 10:41:07.512:812) : item=0 name=/etc/passwd inode=1311021 dev=fc:01 mode=file,644 ouid=root ogid=root nametype=NORMAL
type=SYSCALL msg=audit(10/25/2026 10:41:07.512:812) : arch=x86_64 syscall=utimensat success=yes exit=0 auid=admin uid=root comm=touch exe=/usr/bin/touch key=passwd_watch

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:

bash
LinuxTeck.com
sudo tee /etc/audit/rules.d/50-identity.rules > /dev/null <<'EOF'
-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:

OUTPUT
Old style watch rules are slower
-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

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?

bash
LinuxTeck.com
last -n 5
sudo lastb -n 3
sudo aureport -au --summary | head -8

Expected output:

OUTPUT
admin pts/1 198.51.100.20 Sun Oct 25 09:13 still logged in
admin pts/0 198.51.100.20 Sun Oct 25 09:12 still logged in
reboot system boot 6.8.0-87-generic Sun Oct 25 09:05 still running

oracle ssh:notty 192.0.2.44 Sun Oct 25 09:41 - 09:41 (00:00)
admin ssh:notty 198.51.100.12 Sun Oct 25 09:41 - 09:41 (00:00)
root ssh:notty 203.0.113.7 Sun Oct 25 09:41 - 09:41 (00:00)

Authentication Summary Report
=============================
total auth
=============================
412 yes
3 no

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:

bash
LinuxTeck.com
sudo apt install -y lynis
sudo lynis audit system --quick

Expected output:

OUTPUT
Lynis security scan details:

Hardening index : 74 [############## ]
Tests performed : 268
Plugins enabled : 1

Components:
- Firewall [V]
- Malware scanner [X]

Files:
- Test and debug information : /var/log/lynis.log
- Report data : /var/log/lynis-report.dat

On RHEL/Rocky Linux:

bash
LinuxTeck.com
sudo dnf install -y epel-release
sudo dnf install -y lynis
sudo lynis audit system --quick

Expected output:

OUTPUT
Hardening index : 71 [############## ]
Tests performed : 259

Lynis lives in EPEL on the RHEL side. Skip the scrolling and pull just the important parts from the report file:

bash
LinuxTeck.com
sudo grep -E '^(warning|suggestion)' /var/log/lynis-report.dat | head -5

Expected output:

OUTPUT
warning[]=PKGS-7392|Found one or more vulnerable packages.|-|-|
suggestion[]=SSH-7408|Consider hardening SSH configuration|AllowTcpForwarding (set YES to NO)|-|
suggestion[]=SSH-7408|Consider hardening SSH configuration|AllowAgentForwarding (set YES to NO)|-|
suggestion[]=BOOT-5122|Set a password on GRUB boot loader to prevent altering boot configuration|-|-|
suggestion[]=HRDN-7230|Harden the system by installing at least one malware scanner|-|-|

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.

bash
LinuxTeck.com
#!/bin/bash
# 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:

bash
LinuxTeck.com
chmod +x harden-check.sh
sudo ./harden-check.sh

Expected output:

OUTPUT
[ PASS ] Kernel 6.8.0-87-generic is live
[ PASS ] Single UID 0 account
[ PASS ] No passwordless sudo
[ PASS ] PasswordAuthentication no
[ PASS ] KbdInteractiveAuthentication no
[ PASS ] PermitRootLogin no
[ PASS ] SSH crypto on defaults
[ PASS ] fail2ban active
[ PASS ] ufw active
[ INFO ] 3 sockets on non-loopback addresses
[ INFO ] 12 SUID binaries
[ PASS ] kptr_restrict set
[ PASS ] dmesg_restrict set
[ FAIL ] Journal lives in RAM
[ PASS ] auditd watching /etc/passwd
[ PASS ] Lynis installed
----
PASS: 13 FAIL: 1

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:

bash
LinuxTeck.com
ansible webservers -b -m script -a "harden-check.sh --json" | grep -E 'CHANGED|FAILED|pass'

Expected output:

OUTPUT
web01 | CHANGED | rc=0 >>
{"host":"web01","pass":14,"fail":0}
web02 | FAILED | rc=1 >>
{"host":"web02","pass":13,"fail":1}

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 Tip:

Keep the script in the same Git repo as your server configs. When someone asks "is this box hardened?", the answer becomes a command, not a meeting.

Production Pitfalls and Fixes

These are the ones that bite people after they think they're done.

Issue 01
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.

bash
LinuxTeck.com
sudo ufw status | grep 8080
sudo ss -tlnp | grep 8080

Expected output:

OUTPUT
tcp LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=2210,fd=7))

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.

Issue 02
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.

bash
LinuxTeck.com
grep -ri passwordauthentication /etc/ssh/sshd_config /etc/ssh/sshd_config.d/

Expected output:

OUTPUT
/etc/ssh/sshd_config:PasswordAuthentication no
/etc/ssh/sshd_config.d/50-cloud-init.conf:PasswordAuthentication yes

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.

Issue 03
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.

bash
LinuxTeck.com
sudo ausearch -m CONFIG_CHANGE -i | tail -1

Expected output:

OUTPUT
type=CONFIG_CHANGE msg=audit(10/20/2026 14:48:26.957:21) : auid=unset ses=unset subj=unconfined op=remove_rule key=passwd_watch list=exit res=yes

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.

Issue 04
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.

bash
LinuxTeck.com
sudo sysctl -w net.ipv4.conf.all.rp_filter=2
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:

OUTPUT
net.ipv4.conf.all.rp_filter = 2

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:

bash
LinuxTeck.com
getenforce

Expected output:

OUTPUT
Enforcing

On Ubuntu/Debian:

bash
LinuxTeck.com
sudo aa-status | head -3

Expected output:

OUTPUT
apparmor module is loaded.
121 profiles are loaded.
38 profiles are in enforce mode.

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:

bash
LinuxTeck.com
sudo apt install -y aide
sudo aideinit
sudo aide --check --config /etc/aide/aide.conf | head -6

Expected output:

OUTPUT
Start timestamp: 2026-10-25 11:02:44 +0530 (AIDE 0.18.6)
AIDE found NO differences between database and filesystem. Looks okay!!

Number of entries: 183214

On RHEL/Rocky Linux:

bash
LinuxTeck.com
sudo dnf install -y aide
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:

OUTPUT
Start timestamp: 2026-10-25 11:05:10 +0530 (AIDE 0.18.6)
AIDE found NO differences between database and filesystem. Looks okay!!

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:

bash
LinuxTeck.com
ssh-keygen -t ed25519-sk -O resident -C "admin@yubikey"

Expected output:

OUTPUT
Generating public/private ed25519-sk key pair.
You may need to touch your authenticator to authorize key generation.
Enter PIN for authenticator:
You may need to touch your authenticator again to authorize key generation.
Your identification has been saved in /home/admin/.ssh/id_ed25519_sk

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:

bash
LinuxTeck.com
sudo tee /etc/nginx/conf.d/00-hardening.conf > /dev/null <<'EOF'
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:

OUTPUT
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

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:

bash
LinuxTeck.com
nmap --script ssl-enum-ciphers -p 443 example.com | grep -E 'TLSv1|least strength'

Expected output:

OUTPUT
| TLSv1.2:
| TLSv1.3:
|_ least strength: A

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.

bash
LinuxTeck.com
restic -r /srv/backup/web01 check --read-data-subset=5%

Expected output:

OUTPUT
using temporary cache in /tmp/restic-check-cache-2193650143
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
read 5.0% of data packs
[0:04] 100.00% 31 / 31 packs
no errors were found

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 ss check 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 /usr read-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.
Compliance Note:

Under PCI DSS, HIPAA or SOC 2, an auditor will usually want a named standard. The CIS Benchmark Level 1 profile covers everything in this article and more without breaking common apps, while Level 2 and DISA STIG go further and need testing first. OpenSCAP can scan against both.

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:

bash
LinuxTeck.com
sudo rm -f /etc/ssh/sshd_config.d/10-hardening.conf /etc/sysctl.d/99-hardening.conf /etc/sysctl.d/99-hardening-ipv6.conf
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:

OUTPUT
Firewall stopped and disabled on system startup

On RHEL/Rocky Linux:

bash
LinuxTeck.com
sudo rm -f /etc/ssh/sshd_config.d/10-hardening.conf /etc/sysctl.d/99-hardening.conf /etc/sysctl.d/99-hardening-ipv6.conf
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:

OUTPUT
success
success

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.

LinuxTeck - 12 Linux Server Hardening Practices That Shut the Door on Attackers
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.

About Aneeshya S

Aneeshya S is a Senior Linux Trainer and System Administrator with over 10 years of experience. She actively follows emerging technologies and industry trends. Outside the terminal, she enjoys music and travel.

View all posts by Aneeshya S →

Leave a Reply

Your email address will not be published.

L