Blog
SSH Keys and fail2ban: The Minimal Host-Hardening Checklist
An SSH port on a public IP with no restrictions fills auth.log with password-scanning bots within days. I don't want a "military-grade fortress" — I want a minimal checklist: key login, passwords off, a firewall, fail2ban. The configs are the main thread below, and the incidents live in the margin notes, because in the real world the hardening steps themselves are what hurt the operator.
The homelab threat model is plain: nobody at the state level is watching you, but scripted bots across the whole internet try passwords around the clock. The goal is to go from "a random scan will crack this" to "don't bother without a key." Overdo it and you only raise your own odds of locking the door behind you.
Local keys: generate first, talk to the server later
On the laptop:
ssh-keygen -t ed25519 -a 100 -C "me@laptop"
# -a 100: KDF rounds, slowing brute force on the private key passphrase
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server
Margin note (near-miss): if ssh-copy-id fails on a wrong password but you think it worked, then turning passwords off next is locking the door and losing the key. My habit: right after copying, try ssh -o PasswordAuthentication=no user@server once — if it fails, stop.
The private key must be 600, the directory 700. With looser permissions ssh refuses to use the key outright, which reads like "my key is broken" but is really a self-check.
sshd drop-in: the fully annotated config actually on my disk
Only after a fresh session can get in do I touch the server side. I use a drop-in and never edit the main config file:
sudo tee /etc/ssh/sshd_config.d/99-hardening.conf <<'EOF'
PasswordAuthentication no
# Passwords off: credential-stuffing bots are out of a job; you can only get in with a key
KbdInteractiveAuthentication no
# Kill keyboard-interactive too, so nothing sneaks back in through a password-like path
PermitRootLogin no
# Root doesn't connect directly; use su/sudo when needed, cleaner auditing
PubkeyAuthentication yes
X11Forwarding no
# Servers rarely need X11 forwarding; one less surface
AllowUsers myuser
# An allowlist of users beats hiding by changing the port
# MaxAuthTries 3
# Optional: cap attempts per connection; complements fail2ban
EOF
sudo sshd -t && sudo systemctl reload ssh
Margin note (one night in March 2025): I once introduced a syntax error and skipped the sshd -t check; after reload, every new connection died. My old session survived, so I moved the drop-in out and reloaded again. Since that night the command is a fixed combo: sshd -t && systemctl reload ssh — the second half never runs if the first half fails.
AllowUsers beats changing the port. A port change only cuts noise; it doesn't stop targeted scans. Cleaner still: don't let SSH listen on the public interface at all — bind it to the WireGuard interface. If you can do that, much of the "anti-scan" stuff above becomes a nice-to-have.
fail2ban: jail.local, line by line
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
In /etc/fail2ban/jail.local I only enable sshd:
[sshd]
enabled = true
maxretry = 4
# 4 failures in a 10-minute window triggers a ban; too strict and you lock out your own retries
findtime = 10m
bantime = 1h
# An hour is enough to make scripts move on to another IP; no need to feud with the whole internet forever
Margin note: once I was connecting from behind a coffee shop NAT and the person ahead of me had gotten the IP dirty, so I got banned. Fix: fail2ban-client set sshd unbanip x.x.x.x, and accept that shared egress means collateral damage. When your home egress is fixed, you can have ufw allow only your own IP and fail2ban becomes layer two.
Check status:
sudo fail2ban-client status sshd
sudo journalctl -u ssh -n 50 --no-pager
fail2ban reduces noise; it's not a magic shield. Even with Cloudflare or a panel in front, it's still worth keeping — it's cheap and effective against direct-IP traffic.
ufw: deny by default, then open holes by source
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 10.66.0.0/24 to any port 22
# Only the WireGuard subnet gets SSH
# If it has to be public: sudo ufw allow OpenSSH
sudo ufw enable
Margin note (a near double-kill): the cloud security group already allowed 22, but ufw only trusted 10.66.0.0/24. One day WireGuard was down, I couldn't reach SSH, and I assumed it was a key problem. It was the firewall refusing by design. Rescue came through the vendor's VNC. Both gates are worth checking: ufw status numbered and the cloud console's rule list.
If you can bind to a VPN subnet, don't use 0.0.0.0/0:22. A cloud security group and ufw are two separate gates — don't trust just one.
Full incident review: forty minutes locked out
Timeline:
- Set up the drop-in on a new machine and turned passwords off.
- Without verifying in a new window, closed the old SSH session.
- The laptop's public key wasn't actually in
authorized_keys(the earlier copy had failed and I didn't read the output). - Reconnect rejected; fail2ban hadn't banned me — it was pure auth failure.
- Got in through the vendor's VNC, put the public key in place, ran
sshd -t && reload, verified in a new window.
Rescue paths, now pinned to the top of my password manager:
- Cloud machines: vendor VNC / serial console
- Home machines: plug in a monitor and keyboard
- If another sudo session is alive: flip
PasswordAuthentication yesas a stopgap, fix things properly, then turn it back off
One rule of discipline covers all of it: keep an authenticated session alive before touching sshd, and verify from a fresh window afterward. That single habit beats any scanner signature.
Optional extras: add by threat model, not by checklist
- Run
ssh-auditover the cipher suites - Allow only specific IPs (when your home egress is fixed)
- Jump host, and forbid public SSH on internal machines
- 2FA (PAM): I rarely bother at home; key custody matters more
Scan the logs as part of the routine: failed passwords, unknown usernames, bursts of connections. fail2ban drops the noise, but a strange IP that suddenly logs in successfully is more alarming than any failure count.
Hardening isn't "more software installed equals more secure." The minimal checklist done and the listening surface trimmed beats a ten-page security policy. When the config can be annotated and the incidents can be reviewed, this setup is actually yours.
If you only do one thing: turn off password login, and confirm the key and the rescue path first. fail2ban and ufw can wait until tomorrow — the afternoon you lock yourself out usually arrives on the reload you were most confident about.