A newly provisioned VPS with a public IPv4 address will receive its first unsolicited SSH connection attempt within minutes. Not because anyone is interested in you — because the entire address space is scanned continuously, and a fresh allocation is indistinguishable from any other.
This article is the sequence we work through on every new build. It is deliberately ordered: each step assumes the previous one is done, and doing them out of order is how people lock themselves out.
Everything below assumes a current Debian or Ubuntu. The principles transfer; the package names do not.
Before anything else: keep a second way in
The single most common self-inflicted incident in server administration is locking yourself out while tightening SSH.
Two precautions make it a non-event.
Keep your current session open. Open a second terminal to test changes, and do not close the first until the second works. If you break the configuration, the existing session is still authenticated and can undo it.
Know how to reach the console. Every serious VPS provider offers out-of-band console access — a VNC session or serial console that does not depend on SSH or on the network stack being configured correctly. Find it, log into it once, and confirm it works before you need it. Discovering that your console access requires a password you have never set, at the moment you are locked out, is a bad afternoon.
Step one: a non-root user with a key
Log in as root for the last time.
adduser jsmith
usermod -aG sudo jsmith
Then, from your workstation, generate a key if you do not have one and install it:
ssh-keygen -t ed25519 -C "jsmith@workstation"
ssh-copy-id jsmith@203.0.113.10
Ed25519 rather than RSA: shorter, faster, and no key-size decision to get wrong. Every current SSH implementation supports it.
Now open a second terminal and confirm ssh jsmith@203.0.113.10 works with the key, and that sudo -i works once you are in. Do not proceed until both do.
Step two: lock down SSH
On current Debian and Ubuntu, /etc/ssh/sshd_config begins with an Include /etc/ssh/sshd_config.d/*.conf line. Because SSH takes the first value it encounters for any keyword, settings in those drop-in files override everything later in the main file. Put your changes in a drop-in — it survives package upgrades cleanly and makes your deviations from the default obvious.
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF'
PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 30
EOF
A few of these are worth explaining.
PermitRootLogin prohibit-password allows root login by key but not by password. no is stricter and is fine if you are confident in your sudo access; prohibit-password leaves you a route in via console-installed key if sudo breaks.
KbdInteractiveAuthentication no matters because turning off PasswordAuthentication alone is not always sufficient — on some builds, keyboard-interactive can still offer a password prompt through PAM. Setting both closes the gap.
MaxAuthTries 3 and LoginGraceTime 30 reduce the cost of the constant background noise rather than preventing anything outright.
Validate the configuration before restarting, and inspect what the server will actually apply:
sudo sshd -t # syntax check; silent means valid
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauth|kbdinteractive'
sshd -T prints the fully resolved effective configuration, including everything pulled in by the includes. This is how you confirm a setting has taken, rather than assuming it because you wrote it in a file.
Then restart, and test from the second terminal before closing the first:
sudo systemctl restart ssh
On changing the SSH port
Moving SSH to a high port dramatically reduces log noise from broad scans. It does not meaningfully reduce risk against anyone specifically targeting you, since a port scan finds it in seconds. Treat it as log hygiene, not security.
If you do it, be aware that recent Ubuntu releases start SSH via socket activation. On those systems the listening port is a property of ssh.socket, not of sshd_config, and changing Port in the config alone has no effect:
sudo systemctl edit ssh.socket
[Socket]
ListenStream=
ListenStream=2222
The empty ListenStream= is required — it clears the inherited value rather than adding a second port.
Step three: the firewall
Default-deny inbound, allow outbound, and open only what is needed.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw enable
ufw allow OpenSSH uses the named application profile, which resolves to port 22. If you changed the port, allow that port explicitly before enabling — ufw enable takes effect immediately and will cheerfully cut your session.
Add services as you install them, not in advance:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
The rule that gets forgotten is the database. MySQL, PostgreSQL, Redis and Memcached should not be reachable from the internet at all. Ideally they listen on 127.0.0.1 or a Unix socket and the firewall question never arises. Check what is actually listening:
sudo ss -tlnp
Anything bound to 0.0.0.0 or [::] is reachable from outside unless the firewall stops it. Redis in particular has a long history of being found this way, because its default configuration historically bound broadly and required no authentication.
Step four: automatic security updates
The overwhelming majority of compromised servers are running software with a known, patched vulnerability. Automating security updates addresses more real-world risk than everything else in this article combined.
sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades
The defaults apply security updates only, which is the right balance — you get the patches without unexpected feature changes.
Two settings in /etc/apt/apt.conf.d/50unattended-upgrades are worth a decision rather than a default:
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Automatic-Reboot "false";
The first stops /boot filling up, which is a genuinely common cause of failed upgrades on VPSes with a small boot partition.
The second is a judgement call. Kernel and glibc updates do not take effect until a reboot, so a server that never reboots accumulates patched-but-not-active vulnerabilities. Automatic reboots at a fixed time are appropriate for a stateless web server; less so for anything with long-running jobs. If you leave it off, you need a process that actually reboots the machine — needrestart will tell you when it is required, and it runs by default on Ubuntu after upgrades.
Verify it is running rather than merely installed:
systemctl status unattended-upgrades
sudo unattended-upgrade --dry-run --debug
Step five: respond to brute force
With password authentication disabled, SSH brute forcing cannot succeed — but it still consumes resources and buries real events in log noise. And any other service you expose later (a mail submission port, a control panel login) will face the same treatment without the benefit of key-only authentication.
fail2ban reads logs, matches failure patterns, and inserts temporary firewall bans.
sudo apt install fail2ban
Never edit jail.conf; it is replaced on upgrade. Create /etc/fail2ban/jail.local:
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
backend = systemd reads from the journal rather than from a log file. This matters on current Debian and Ubuntu, where rsyslog is not always installed and /var/log/auth.log may not exist — a file-based backend then silently matches nothing, and fail2ban appears to be running while doing absolutely nothing.
Check that it is actually catching things:
sudo fail2ban-client status sshd
The banned count should be non-zero within a day of the server being reachable. If it is zero, the jail is not matching.
CrowdSec is a reasonable alternative, and takes a different approach: local detection combined with a shared reputation feed, so you can block addresses that have attacked other participants before they reach you. It is more moving parts; whether that is worth it depends on how much you are exposing.
What this does and does not achieve
An hour’s work gets you: no password authentication, no unnecessary exposed ports, patches applied automatically, and brute-force attempts blocked. That is the automated-attack baseline, and it is the difference between being compromised by a script and not being.
It does not address application-layer security at all. The most likely route into a hardened server is not SSH — it is an outdated plugin on a website it hosts, a leaked database credential in a repository, or a file upload handler that does not validate what it accepts.
It also does nothing without monitoring. A hardened server that is compromised anyway, with nobody watching, is in the same position as an unhardened one. Log shipping, uptime checks and file integrity monitoring are the next hour’s work.
FXRM’s UK VPS hosting runs KVM on Proxmox, with managed options where you would rather we did the hardening. See our VPS hosting →