To secure an Ubuntu VPS, turn off SSH passwords, allow only the ports you serve with ufw, add fail2ban, enable automatic security updates and reboot after kernel updates. That is what our 23-line hardening.sh does. We ran it on our production Hetzner CX23 with Ubuntu 26.04 on 2026-11-05, and a password login attempt from outside now ends with Permission denied (publickey).
Why this matters now: Ubuntu 24.04 and 26.04 start SSH through ssh.socket, so half the hardening guides online tell you to run a reload command that fails. Bots begin guessing passwords on new servers quickly; finishing this before you install apps closes that door. This page sits in our what you need after buying a VPS section.
Tested in the Lab Hetzner CX23 (production, Ubuntu 26.04) · Nov 2026
Before you run anything
The script disables password login. If your key does not work yet, you lock yourself out. So:
- Add your public key when you create the server (Hetzner and most providers have a field for it).
- Log in with the key and keep that session open for the whole process.
- Know where your provider's web console is. It works without SSH if something goes wrong.
The script
#!/bin/bash
# The hardening we ran on our n8n server (Ubuntu 26.04 LTS, Hetzner CX23) on 2026-11-05.
# Run as root AFTER your SSH key login works (test it in a second terminal first).
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update && apt-get -y upgrade
apt-get -y install unattended-upgrades ufw fail2ban
# key-only SSH, root only with a key
cat > /etc/ssh/sshd_config.d/10-lab-hardening.conf <<CONF
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
CONF
sshd -t
# Ubuntu 24.04+ starts sshd through ssh.socket: "systemctl reload ssh" fails with
# "ssh.service is not active". New connections already read the new config.
systemctl reload ssh 2>/dev/null || echo "ssh.socket activation: config applies to new connections"
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
systemctl enable --now fail2ban
[ -f /var/run/reboot-required ] && echo "Reboot required: run 'systemctl reboot'"
Line by line
set -e and DEBIAN_FRONTEND=noninteractive
set -e stops the script at the first failing command. That matters most at sshd -t: a broken SSH config stops the run before anything else touches SSH. The second line tells apt not to open interactive dialogs, so the upgrade cannot hang waiting for a keypress.
apt-get update && apt-get -y upgrade
Fetches the package lists and installs every pending update. A fresh cloud image is often weeks behind. On our server this pulled a new kernel and libraries, which is why the last line checks for a reboot.
apt-get -y install unattended-upgrades ufw fail2ban
- unattended-upgrades downloads and installs security upgrades on a schedule (the
apt-daily-upgradetimer), so a forgotten server still gets patched. Its settings live in/etc/apt/apt.conf.d/50unattended-upgrades. - ufw ("uncomplicated firewall") is a front end to the kernel firewall.
- fail2ban watches login failures and bans the source address for a while.
The SSH drop-in file
We write a separate file in /etc/ssh/sshd_config.d/ instead of editing sshd_config. Package updates can then replace the main file without undoing our settings, and the change is easy to find and remove. The name starts with 10- so it sorts early: for most options sshd uses the first value it reads.
PasswordAuthentication no: no password logins at all. This is the line that stops brute-force guessing.KbdInteractiveAuthentication no: closes the keyboard-interactive path, which can otherwise still prompt for a password through PAM.PermitRootLogin prohibit-password: root may log in, but only with a key.
Candidly, two of the three lines restate defaults: the Ubuntu man page lists prohibit-password as the default, and Debian's package already sets KbdInteractiveAuthentication no. We write them anyway so that a drop-in from a cloud image or a later edit cannot quietly reopen them. The line that changes behaviour is PasswordAuthentication no.
sshd -t
Test mode. It parses the whole configuration and exits with an error if anything is wrong. To see the values sshd will actually use, run sshd -T | grep -Ei 'passwordauthentication|kbdinteractive|permitrootlogin'.
The reload line and the ssh.socket gotcha
On our server, systemctl reload ssh failed with:
ssh.service is not active
Since Ubuntu 22.10, OpenSSH uses systemd socket activation by default: ssh.socket listens on port 22 and sshd starts only when a connection arrives. Between connections there is no running service to reload. That is fine for us, because sshd reads its config when each new connection starts it. The || echo keeps set -e from aborting on that expected failure. If you change the port or listen address, the rules differ: you need systemctl daemon-reload and systemctl restart ssh.socket.
The ufw lines
ufw allow OpenSSH comes before ufw --force enable, so the firewall never starts without port 22 open. Ports 80 and 443 are for Caddy and the Let's Encrypt challenge; leave them out if the server runs no web service. --force skips the "may disrupt existing ssh connections" prompt. Everything else inbound is denied by ufw's default policy.
systemctl enable --now fail2ban
Starts fail2ban now and at every boot. On Ubuntu the package ships /etc/fail2ban/jail.d/defaults-debian.conf, which enables the sshd jail and reads the systemd journal. The upstream defaults ban an address for 10 minutes after 5 failures within 10 minutes. We kept them. If you change them, use a jail.local; the upstream file says not to edit jail.conf.
The reboot check
Ubuntu creates /var/run/reboot-required when an update needs a restart, typically a new kernel. We rebooted by hand with systemctl reboot.
How to test that password login is refused
Do this from your own computer, not the server. The command forces SSH to skip your key and try only a password:
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no root@YOUR_SERVER_IP
Our production server answers:
root@62.238.97.113: Permission denied (publickey).
No password prompt appears: the server only offers publickey. If you get a password prompt, the drop-in is not active; run sshd -T on the server and look for a file in sshd_config.d that sets PasswordAuthentication yes earlier in the sort order (some cloud images ship one). Then check the rest:
ufw status verbose
fail2ban-client status sshd
systemctl status unattended-upgrades --no-pager
From outside, only ports 22, 80 and 443 answer on our server. Port 5678 (n8n) and 5432 (Postgres) do not, because they are bound to 127.0.0.1 or not published.
One trap outside the script: Docker skips ufw
Docker's own docs say published container ports are routed before ufw's rules apply. A compose line like "5678:5678" is public even with ufw on. Bind to 127.0.0.1:5678:5678 and let a reverse proxy handle the outside, as in our n8n install on Hetzner.
What we did not do, and why
| Common advice | Our choice | Reason |
|---|---|---|
| Move SSH off port 22 | Kept 22 | With passwords off, a new port only cuts log noise. On Ubuntu 24.04+ the port runs through ssh.socket and its generator; one more place to get it wrong and lock yourself out. |
| Create a sudo user and forbid root | Root with key only | A single-admin server run by one person. prohibit-password removes the password risk. With a team, give each person a user instead. |
| Two-factor for SSH | Not added | A key already is "something you have". PAM-based 2FA adds a lockout path. Put 2FA on your cloud account and app logins instead. |
| Custom fail2ban jails | Package defaults | The sshd jail is already on; with key-only login it mostly reduces noise. |
| Automatic reboots | Manual | We reboot when reboot-required appears, so n8n never restarts mid-workflow at a random hour. |
| Kernel sysctl tuning, intrusion detection | Not done | No measured benefit for a small app server; more to maintain. We may add it later and say so here. |
We are an engineer-minded lab, not certified security auditors. This is a sensible baseline for a single-purpose app server. If you hold customer data under regulation, get a professional review on top of it.
Next step: see where hardening fits among all ten first steps in our VPS setup checklist.
FAQ
Why does systemctl reload ssh fail on Ubuntu 24.04 and 26.04?
sshd is started through ssh.socket when a connection arrives, so ssh.service is often not running. Reload then fails with ssh.service is not active. New connections read the new config anyway.
Should I change the SSH port to secure my Ubuntu VPS?
We did not. With password login off, a different port only reduces log noise. On Ubuntu 24.04 and later the port is wired through ssh.socket, which adds a step where you can lock yourself out.
How do I test that SSH password login is disabled?
From another machine run ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no root@YOUR_SERVER_IP. A hardened server answers Permission denied (publickey) without asking for a password.
Do I still need fail2ban if passwords are off?
It is not strictly needed for SSH then, but it is cheap. On Ubuntu the package enables the sshd jail by default and bans an address for 10 minutes after 5 failures in 10 minutes.
Does ufw protect Docker containers?
Not for published ports. Docker's ports skip ufw rules, so bind container ports to 127.0.0.1 and put a reverse proxy in front.
Sources
- Ubuntu Discourse, SSHd now uses socket-based activation (2022)
- UbuntuHandbook, Install and use SSH in Ubuntu 24.04 (2024)
- Debian fail2ban package, jail.d/defaults-debian.conf
- fail2ban, jail.conf defaults (GitHub)
- fail2ban Wiki, Proper fail2ban configuration
- Docker Docs, Packet filtering and firewalls (2026)
- Ubuntu Manpages, sshd_config(5)
- Ubuntu Manpages, unattended-upgrade(8)
