Lock Down a Fresh VPS in 20 Minutes: SSH Hardening That Survives Real Attacks
A fresh VPS with a public IP starts getting SSH login attempts within about ten minutes. Not because anyone knows about you - because the entire IPv4 space is scanned continuously. Check any server you have had running for a week:
journalctl -u ssh --since "24 hours ago" | grep -c "Failed password"
Four figures is normal. My thesis: you do not need forty settings, you need five, and you need a rollback plan open in a second terminal while you apply them. Everything else - port changes, 2FA, port knocking - is optional polish that people do instead of the five things that matter. Here is the twenty-minute run.
Minute 0: take a snapshot and open a second terminal
Before touching anything, take a provider snapshot and confirm you know how to reach the console (Hetzner, DigitalOcean, Linode and Contabo all offer a browser VNC). That console is your break-glass path. Then open a second SSH session and leave it open for the whole exercise. Every lockout story I know involves someone closing their only session to "test".
Minutes 1-4: generate a key on your laptop, not the server
# On YOUR machine, never on the VPS
ssh-keygen -t ed25519 -a 100 -C "karani@thinkpad-2026" -f ~/.ssh/id_ed25519_prod
ssh-copy-id -i ~/.ssh/id_ed25519_prod.pub root@203.0.113.42
Ed25519 over RSA: shorter, faster, and no key-size arguments. -a 100 sets KDF rounds so a stolen private key is expensive to brute force. Use a passphrase and load it into an agent so you type it once per day:
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519_prod
Then make your life easier with a client config:
# ~/.ssh/config
Host prod
HostName 203.0.113.42
User karani
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
ServerAliveInterval 60
IdentitiesOnly yes matters more than it looks: without it, your agent offers every key you own, and a server with MaxAuthTries 3 will disconnect you before it reaches the right one.
Minutes 4-7: a non-root user with sudo
sudo adduser --gecos "" karani
sudo usermod -aG sudo karani
sudo install -d -m 700 -o karani -g karani /home/karani/.ssh
sudo cp /root/.ssh/authorized_keys /home/karani/.ssh/authorized_keys
sudo chown karani:karani /home/karani/.ssh/authorized_keys
sudo chmod 600 /home/karani/.ssh/authorized_keys
Now test in your second terminal, before changing any sshd setting:
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_prod karani@203.0.113.42 'id; sudo -n true && echo SUDO_OK'
If that does not print your uid and SUDO_OK, stop and fix it. Do not proceed.
Minutes 7-12: the sshd config, and the trap nobody mentions
Ubuntu's /etc/ssh/sshd_config begins with Include /etc/ssh/sshd_config.d/*.conf. Drop-ins are read in lexical order and sshd keeps the first value it sees for most keywords. Cloud images ship /etc/ssh/sshd_config.d/50-cloud-init.conf containing PasswordAuthentication yes. So a file called 99-hardening.conf loses. Look before you write:
ls -l /etc/ssh/sshd_config.d/
sudo grep -riE 'passwordauth|permitrootlogin' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/
Name your file so it wins:
sudo tee /etc/ssh/sshd_config.d/01-hardening.conf > /dev/null <<'EOF'
# Identity
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
AuthenticationMethods publickey
PubkeyAuthentication yes
# Who may connect at all
AllowUsers karani deploy
# Brute force surface
MaxAuthTries 3
MaxSessions 5
MaxStartups 10:30:60
LoginGraceTime 20
# Reduce what a compromised session can reach
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
# Drop dead sessions
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
KbdInteractiveAuthentication no is the line people skip. With PasswordAuthentication no alone, PAM can still serve an interactive password prompt through the keyboard-interactive mechanism - you think passwords are off and they are not.
AllowTcpForwarding no is the right default for an app server but will break your ssh -L 3306:localhost:3306 database tunnel. Decide deliberately; do not just copy it.
Now verify the effective configuration, which is the only thing that counts:
sudo sshd -t # syntax check, prints nothing if OK
sudo sshd -T | grep -iE 'permitrootlogin|passwordauthentication|kbdinteractive|maxauthtries|allowusers|^port'
You want to see exactly this:
port 22
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
maxauthtries 3
allowusers karani deploy
If passwordauthentication still says yes, a lower-numbered drop-in is beating you. Fix that file rather than adding another.
Minutes 12-14: reload and test from a NEW terminal
sudo systemctl restart ssh
Then, keeping both existing sessions open, from a third terminal:
ssh -v prod # should succeed with publickey
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no prod # should be refused
The second command must fail with Permission denied (publickey). If it prompts for a password, your config is not in effect.
The Ubuntu 24.04 socket activation trap
Since Ubuntu 22.10, OpenSSH is socket-activated by default: ssh.socket listens on port 22 and spawns sshd per connection. The consequence catches almost everyone - Port and ListenAddress in sshd_config are ignored. You set Port 2222, restart, get no error, reconnect on 22 anyway, then firewall off port 22 and lock yourself out.
Check which mode you are in:
systemctl is-enabled ssh.socket ssh.service
ss -tlnp | grep -E ':22|:2222'
If you genuinely want a different port under socket activation:
sudo systemctl edit ssh.socket
[Socket]
ListenStream=
ListenStream=2222
The empty ListenStream= is mandatory - it clears the inherited value. Without it you listen on both 22 and 2222. Then:
sudo ufw allow 2222/tcp
sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
ss -tlnp | grep 2222
Or go back to the classic model, which many people prefer for predictability:
sudo systemctl disable --now ssh.socket
sudo systemctl enable --now ssh.service
My opinion: changing the port is noise reduction, not security. It cuts your log volume by 95 percent, which is genuinely useful for finding real events. It stops zero determined attackers. Do it for the logs, not for the safety.
Minutes 14-17: firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit 22/tcp comment 'ssh rate limited'
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status numbered
ufw limit blocks an IP that opens more than 6 connections in 30 seconds - free, built in, and it removes most of the low-effort scanning. Note that UFW rules do not apply to Docker's published ports, because Docker writes its own iptables chains ahead of UFW's. If you run Docker, bind containers to 127.0.0.1:8080:8080 and let Nginx reach them, rather than assuming UFW protects you.
Minutes 17-20: fail2ban and automatic updates
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[DEFAULT]
backend = systemd
bantime = 1h
findtime = 10m
maxretry = 4
ignoreip = 127.0.0.1/8 ::1
[sshd]
enabled = true
mode = aggressive
[sshd-ddos]
enabled = false
EOF
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
On Ubuntu 24.04 the systemd backend is the correct choice because there is no /var/log/auth.log monitoring to rely on in a journald-first world - the file backend can silently match nothing and you get a jail that looks healthy and bans nobody. Always confirm with fail2ban-client status sshd and check that Currently failed is climbing.
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
sudo unattended-upgrade --dry-run --debug | tail -20
Enable security updates only. Auto-installing every update on a production box is how you wake up to a new MariaDB minor version at 04:00.
What I deliberately left out, and why
- Port knocking. Adds a moving part that fails at the worst time. The security gain over key-only auth is negligible.
- TOTP 2FA on SSH. Worth it for a jump host with several admins. On a single-owner app server with key-only auth and no passwords, it mostly adds ways to get locked out.
- Disabling root's key entirely.
PermitRootLogin noalready blocks interactive root login. Some provider rescue flows need root; keep the option available through the console. - Exotic ciphers and KEX lists. OpenSSH 9.x defaults are good. Hand-pasted crypto lists from 2016 blogs make things worse, not better.
Verify from the outside
ssh -Q key # what your client supports
ssh-audit 203.0.113.42 # pip install ssh-audit
nmap -sV -p 22,2222,80,443 203.0.113.42
sudo lastb | head -20 # failed logins, if btmp exists
journalctl -u ssh --since today | grep -E "Accepted|Invalid user" | tail -30
After a week, run that Failed password count again. On a key-only box it should be zero, because attackers never get to the password stage. What you will see instead is a stream of Connection closed by authenticating user and Invalid user admin - noise, safely bouncing off a closed door.
The five that matter: no passwords, no root login, key-only auth, an explicit AllowUsers list, and a firewall that defaults to deny. Everything after that is decoration.
Twenty minutes, done once, on every server you build. Put the commands in a shell script and run it as the first thing after provisioning, so it stops being a decision and becomes a habit.