Every year I tell myself I'm going to write down the hardening steps I actually apply to production servers, instead of relying on memory and a scattered pile of old notes. This is that list for 2026. It's not theoretical. Every setting here is something I've deployed on real boxes, and every mistake in the "common mistakes" section is something I've either done myself or watched a colleague do at 2 AM during an incident.
Server hardening isn't a one-time task you check off and forget. It's a baseline you establish, then defend against drift. New CVEs land, packages get reinstalled with default configs, and someone on the team always opens a port "just for testing" and forgets to close it. This checklist gives you a repeatable process, not just a list of settings to paste in once.
Prerequisites
Before you touch anything, make sure you have out-of-band access to the server. This is the single most important prerequisite and the one people skip most often. If you're hardening SSH or firewall rules remotely and you lock yourself out, you need a way back in that doesn't depend on the network stack you just broke. That means IPMI, a cloud provider's serial/VNC console, or physical access.
You'll also want:
- Root or sudo access on the target host, and a non-root administrative account already created (in my examples I use infrarunbook-admin).
- A recent full backup or snapshot, taken before you start. Hardening changes can break legacy applications that depend on loose permissions or weak crypto.
- A change window communicated to whoever depends on this server, especially if it's a shared box with cron jobs or scheduled batch processes.
- A working configuration management setup (Ansible, Salt, or even a well-tested shell script) if you're hardening more than one host. Doing this by hand on ten servers is how inconsistency creeps in.
I'm assuming a Debian/Ubuntu-based server in most examples below (host sw-infrarunbook-01, internal address 10.20.30.15), but the concepts translate directly to RHEL-family distros with different package names.
Step-by-step setup
1. Update everything first
Don't harden a server that's three months behind on patches. Get it current before you start layering on configuration changes, otherwise you won't know if a problem you hit later is caused by your hardening or by an unpatched bug.
apt update && apt full-upgrade -y
apt install unattended-upgrades apt-listchanges -y
dpkg-reconfigure --priority=low unattended-upgrades
I enable unattended upgrades for security patches only, not full-upgrade automation. In my experience, automating full upgrades unattended on production servers eventually breaks something at 3 AM with nobody watching. Security patches are a safer default to automate.
2. Lock down SSH
SSH is the front door on almost every server you'll ever touch, so it gets disproportionate attention here. Create your admin user first, copy your public key over, and confirm you can log in with it before you disable password authentication.
adduser infrarunbook-admin
usermod -aG sudo infrarunbook-admin
mkdir -p /home/infrarunbook-admin/.ssh
cp /root/.ssh/authorized_keys /home/infrarunbook-admin/.ssh/
chown -R infrarunbook-admin:infrarunbook-admin /home/infrarunbook-admin/.ssh
chmod 700 /home/infrarunbook-admin/.ssh
chmod 600 /home/infrarunbook-admin/.ssh/authorized_keys
Test that login before editing sshd_config. Open a second terminal session and confirm key-based login works while your existing root session is still open. If you skip this step and something's wrong with the key, you'll find out the hard way when you're already locked out.
3. Configure the firewall
Default deny incoming, default allow outgoing, and only open what you actually need. I use ufw on Debian-family hosts because it's readable at a glance, even though it's just a wrapper around iptables/nftables.
ufw default deny incoming
ufw default allow outgoing
ufw allow 2222/tcp comment 'ssh'
ufw allow from 10.20.30.0/24 to any port 5432 proto tcp comment 'postgres internal only'
ufw limit 2222/tcp
ufw enable
Note the ufw limit line. That rate-limits new connections on the SSH port, which blunts basic brute-force attempts without needing a separate tool. I still run fail2ban on top of it, because limit and fail2ban catch slightly different attack patterns and the overlap is cheap.
4. Harden the kernel with sysctl
Kernel-level network hardening catches a whole class of spoofing and redirection attacks before they ever reach an application. This is the step people skip most often because it feels abstract, but it's cheap to apply and it works quietly in the background forever.
# /etc/sysctl.d/99-hardening.conf
net.ipv4.conf.all.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.icmp_echo_ignore_broadcasts = 1
net.ipv4.tcp_syncookies = 1
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_ra = 0
kernel.randomize_va_space = 2
kernel.dmesg_restrict = 1
fs.suid_dumpable = 0
sysctl -p /etc/sysctl.d/99-hardening.conf
5. Install and configure auditd
Auditing isn't prevention, it's the thing that tells you what happened after prevention fails. I've closed out more than one incident review by pulling exact timestamps from auditd logs that nothing else on the box had captured.
apt install auditd audispd-plugins -y
systemctl enable auditd --now
At minimum, watch changes to authentication files, sudoers, and the audit configuration itself:
# /etc/audit/rules.d/hardening.rules
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k sudoers_change
-w /etc/ssh/sshd_config -p wa -k sshd_config
-w /var/log/auth.log -p wa -k auth_log
-e 2
That final -e 2 line locks the audit configuration so it can't be changed without a reboot, not even by root. That's intentional. It means if an attacker gets root, they can't quietly disable auditing without leaving a very obvious trace.
6. Restrict cron, at, and set proper file permissions
Cron and at are frequently used for persistence once someone has a foothold, so I explicitly allowlist who can use them rather than leaving it open by default.
rm -f /etc/cron.deny /etc/at.deny
echo "infrarunbook-admin" > /etc/cron.allow
echo "infrarunbook-admin" > /etc/at.allow
chmod 600 /etc/cron.allow /etc/at.allow
chmod 700 /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly
7. Disable unused services and remove unnecessary packages
Every running service is attack surface, whether or not it's actually being used. I go through systemctl list-unit-files --state=enabled on every new server and disable anything I can't justify.
systemctl list-unit-files --state=enabled
systemctl disable --now avahi-daemon cups rpcbind
apt purge avahi-daemon cups-common -y
apt autoremove -y
Full configuration example
Here's a consolidated sshd_config that reflects the combined result of the SSH steps above, tuned for a typical internal application server. Adjust the port and AllowUsers line for your environment.
# /etc/ssh/sshd_config
Port 2222
Protocol 2
AddressFamily inet
ListenAddress 10.20.30.15
PermitRootLogin no
PasswordAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
AllowUsers infrarunbook-admin
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
KexAlgorithms curve25519-sha256,diffie-hellman-group16-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
Banner /etc/issue.net
LogLevel VERBOSE
After editing, always validate the syntax before restarting the service:
sshd -t
systemctl restart sshd
Keep that existing SSH session open while you restart. If the new config has a problem, you want a live session to fall back on rather than discovering the failure by being locked out entirely.
Verification steps
Once everything is applied, don't just trust that it worked. Verify each layer independently.
# Confirm SSH is only listening where expected
ss -tlnp | grep sshd
# Confirm root login and password auth are actually rejected
ssh -o PreferredAuthentications=password -p 2222 infrarunbook-admin@10.20.30.15
# Confirm firewall rules match intent
ufw status verbose
# Confirm sysctl values took effect
sysctl net.ipv4.conf.all.accept_redirects net.ipv4.tcp_syncookies kernel.randomize_va_space
# Confirm auditd is running and rules are loaded
auditctl -l
systemctl status auditd --no-pager
# Confirm the audit config is actually immutable
auditctl -s | grep enabled
# Confirm no unexpected services are listening
ss -tlnp
# Confirm cron access is restricted
cat /etc/cron.allow
I also run a quick external port scan from a separate machine on the same network segment, just to see the server the way an attacker on that segment would see it. It's surprising how often that turns up a listener you forgot about, usually something a package installed as a side effect and enabled by default.
nmap -sT -p- 10.20.30.15
If you're managing more than a handful of hosts, run a CIS benchmark scanner (OpenSCAP works well on RHEL-family systems, Lynis is a lighter-weight option that works everywhere) as a periodic check rather than a one-time audit. Configuration drift is real, and a server that was hardened six months ago is not guaranteed to still be hardened today.
Common mistakes
The mistake I see most often is disabling password authentication before confirming key-based login actually works. It takes thirty seconds to test in a second terminal window, and skipping that step has cost people entire evenings of console access requests to get back into a box.
Another one: hardening sysctl settings on a server that's actually a router, NAT gateway, or load balancer, without realizing some of those settings assume the host isn't forwarding traffic. Setting net.ipv4.ip_forward = 0 blindly across your fleet will quietly break anything that depends on forwarding. Know your host's role before applying a blanket template.
I've also seen teams treat the firewall as the only layer of defense and skip host-based controls entirely, on the theory that "the cloud security group already blocks that." Security groups and firewalls get misconfigured, get temporarily opened for debugging and never closed, or get bypassed entirely if the workload moves. Defense in depth means the host itself shouldn't assume the network in front of it is trustworthy.
Locking the audit configuration with -e 2 and then forgetting you did it is a subtler trap. Six months later someone tries to add a new audit rule, it silently fails to apply, and they spend an hour confused before remembering the config requires a reboot to change. Document that choice somewhere your future self, or your teammate, will actually see it.
Finally, watch out for hardening that never gets revisited. I've walked into environments where the hardening checklist was applied once, years ago, on a golden image, and every server since has been cloned from that image without anyone checking whether the settings still match current guidance or whether drift had crept in through manual changes. Treat this checklist as a baseline to re-verify on a schedule, not a box to check once and forget.
