02 · Initial Server Hardening Checklist¶
A freshly created server (especially one you got with root/password login) is not safe to leave as-is for more than a few minutes on the public internet — automated scanners will find port 22 and start trying credentials almost immediately. This module is the checklist you run through on every new box before doing anything else.
1. Create a non-root user with sudo¶
Never operate as root day-to-day. Create a dedicated administrative user:
adduser (Debian/Ubuntu) walks you through setting a password and optional
account info interactively. On RHEL/CentOS-family systems, use:
(wheel is the sudo-equivalent group on RHEL-family distros.)
Verify sudo works before you do anything else, from a second terminal (so you don't lock yourself out if something's wrong):
2. Install your SSH key for the new user¶
Repeat the key-installation step from module 1, but for deploy instead of
root:
Confirm key-based login works for deploy before touching SSH config.
3. Harden the SSH daemon¶
Edit /etc/ssh/sshd_config (as root or via sudo):
# /etc/ssh/sshd_config
Port 22
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
X11Forwarding no
MaxAuthTries 3
AllowUsers deploy
What each line buys you:
PermitRootLogin no— root can no longer log in over SSH at all, even with a key. You escalate viasudofromdeployinstead.PasswordAuthentication no— kills the entire class of password-guessing attacks against SSH. Only key-based auth is accepted.MaxAuthTries 3— caps how many auth attempts a single connection gets.AllowUsers deploy— an explicit allowlist; anyone else can't even attempt to authenticate, regardless of credentials.
Validate the config syntax before restarting the daemon — a typo here can lock you out permanently:
If that prints nothing, the config is syntactically valid. Then restart:
sudo systemctl restart ssh # Debian/Ubuntu service name
# sudo systemctl restart sshd # RHEL/CentOS service name
Keep your current SSH session open while you test a brand-new connection
in another terminal. Only close the original session once you've confirmed
the new one logs in cleanly as deploy with your key. This is the single
most important safety habit in server hardening — never close your only
working connection until you've proven the new config works.
4. Set up a firewall¶
ufw (Debian/Ubuntu)¶
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH # or: sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
ufw ("uncomplicated firewall") is a friendly frontend over iptables/
nftables. The order matters: allow SSH before you enable, or you'll
cut yourself off the moment the firewall activates.
iptables (lower-level, portable to more distros)¶
The equivalent raw iptables rules:
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
Raw iptables rules don't persist across reboot by default — on Debian/
Ubuntu install iptables-persistent (apt install iptables-persistent) or,
more simply, just use ufw, which handles persistence for you.
firewalld (RHEL/CentOS/Fedora)¶
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
5. Install and configure fail2ban¶
fail2ban watches log files for repeated auth failures and temporarily bans the offending IP at the firewall level — useful defense-in-depth even with password auth already disabled (it also covers other services like web app login forms if you configure a filter for them).
A minimal SSH jail, /etc/fail2ban/jail.local:
Restart to apply: sudo systemctl restart fail2ban.
6. Keep the system patched¶
Consider unattended security upgrades (Debian/Ubuntu):
Worked example: the full checklist, in order¶
# 1-2. user + key (as root)
adduser deploy
usermod -aG sudo deploy
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@203.0.113.10
# --- switch to a NEW terminal, log in as deploy, confirm sudo works ---
# 3. SSH hardening (edit /etc/ssh/sshd_config as shown above, then:)
sudo sshd -t && sudo systemctl restart ssh
# --- open ANOTHER new terminal, confirm deploy@host still logs in ---
# 4. firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
# 5. fail2ban
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
# 6. patch
sudo apt update && sudo apt upgrade -y
Order matters
Always confirm the new access path works before you close off the old one. That applies to SSH config changes, firewall rules, and user permissions alike.
How It Actually Works¶
PAM and the login decision chain. When sshd authenticates a user, it
doesn't make the accept/reject call alone — it consults the Pluggable
Authentication Modules (PAM) stack (/etc/pam.d/sshd), a sequence of modules
each returning success/failure/ignore, combined with required/requisite/
sufficient/optional control flags. Disabling password auth in
sshd_config (PasswordAuthentication no) short-circuits this at the SSH
protocol layer before PAM's password module is even reached, which is why
it's more effective than just adding a strong password: an attacker can't
present a password to be checked in the first place.
Why disabling root login narrows the attack surface disproportionately.
PermitRootLogin no doesn't make root inaccessible — you still sudo to it
— but it eliminates the single highest-value credential from remote guessing
entirely, since every Linux box has a root account by definition (unknown
usernames must first be discovered) while a non-default sudo user's name is
not guessable from the OS alone. Combined with sudo, actions get attributed
to a real username in /var/log/auth.log, whereas shared root logins produce
audit trails with no accountability.
How a firewall (ufw/iptables/nftables) actually filters packets. The
kernel's netfilter subsystem intercepts every packet at defined hook points
in the IP stack (PREROUTING, INPUT, FORWARD, OUTPUT, POSTROUTING).
ufw is a friendly frontend that writes iptables/nftables rules into the
INPUT chain: each incoming packet is compared against rules in order,
top to bottom, and the first matching rule's target (ACCEPT, DROP,
REJECT) decides the packet's fate — this is why rule order matters and
why a catch-all deny is placed last. ACCEPT lets the packet continue up
the stack to the listening socket; DROP silently discards it (the sender's
connection attempt just times out, revealing nothing); REJECT sends back an
explicit ICMP/TCP-RST refusal. Default-deny-incoming means every port not
explicitly opened never reaches an application socket, regardless of what's
listening there.
Fail2ban's mechanism. fail2ban tails log files (/var/log/auth.log)
with regex "filters," and when a matching failure pattern (e.g., repeated
Failed password) recurs past a threshold within a time window, it invokes
an "action" — almost always inserting a temporary firewall rule that drops
all traffic from that source IP for a set ban duration. It is reactive
log-parsing plus firewall automation, not a kernel-level defense — it works
entirely downstream of the same netfilter chains ufw configures.
Exercise¶
Using the VM from module 1:
- Create a
deployuser with sudo access and install your SSH key for it. - Harden
sshd_configper this module, validate withsshd -t, and restart — verifying a fresh connection works before closing your original session. - Enable
ufw(orfirewalld) allowing only SSH, HTTP, and HTTPS. - Install and enable
fail2banwith the jail above. - Run
sudo ufw status verboseandsudo systemctl status fail2banand confirm both show active/running.