Asia VPNAsiaVPNGet app
All posts

Hardening a Public VPS: The First 30 Minutes

5 min read

Every self-hosting guide ends with a paragraph called "lock the box down" and then stops. This is that paragraph, written out properly.

None of it is exotic. You are not defending against a targeted attacker - you are defending against the automated scanners that find a new IP within minutes of it existing, and against yourself in six months when the box has not been patched. Thirty minutes now covers both.

It applies to short-lived boxes too, and those are the ones people skip. A machine rented for an afternoon - a throwaway test server, or a GPU instance for a batch job - is exposed the moment it has a public IP, and gets a fraction of the attention a server you keep for a year does.

First, look at what is already happening

Before changing anything, see the traffic your box is already receiving. On a server that has been up for a day:

sudo journalctl -u ssh --since "24 hours ago" | grep -c "Invalid user"
sudo lastb | head -20

On a fresh VPS with password authentication enabled, those numbers are usually in the thousands. That is the baseline. Everything below is about making that traffic irrelevant rather than merely quieter.

1. SSH keys - and the two traps

Key-only authentication is the single change that matters most. Everything else on this list is defence in depth; this one closes the front door.

From your own machine:

ssh-copy-id user@YOUR_SERVER_IP
ssh user@YOUR_SERVER_IP   # confirm it works BEFORE disabling passwords

Then, on the server, the usual advice is to edit /etc/ssh/sshd_config:

PasswordAuthentication no
PermitRootLogin prohibit-password
KbdInteractiveAuthentication no

On a modern Ubuntu cloud image, that edit does nothing. Two reasons, and both catch experienced people.

Trap 1: OpenSSH is first-match-wins

Most config formats let a later line override an earlier one. OpenSSH does the opposite - the first occurrence of a keyword wins. And sshd_config places its Include /etc/ssh/sshd_config.d/*.conf line at the top of the file, before anything else is set.

So every drop-in file in sshd_config.d/ takes precedence over the main config, not the other way round. Ubuntu cloud images ship /etc/ssh/sshd_config.d/50-cloud-init.conf, and it commonly contains:

PasswordAuthentication yes

Which means your careful PasswordAuthentication no further down the main file is read second and ignored. Check before you trust it:

ls /etc/ssh/sshd_config.d/
sudo sshd -T | grep -iE "passwordauthentication|permitrootlogin|pubkeyauth"

sshd -T prints the effective configuration after all includes are resolved. It is the only output worth believing. Put your settings in a drop-in that sorts early, or fix the cloud-init file directly:

sudo tee /etc/ssh/sshd_config.d/00-hardening.conf <<'EOF'
PasswordAuthentication no
PermitRootLogin prohibit-password
KbdInteractiveAuthentication no
EOF
sudo sshd -t && sudo systemctl reload ssh
sudo sshd -T | grep -i passwordauthentication   # verify again

Note sshd -t before the reload. It validates the config; skipping it is how people lock themselves out of a machine with no console.

Trap 2: socket activation ignores your Port

Since Ubuntu 22.10, sshd is socket-activated. ssh.socket owns the listening port and starts sshd per connection. The consequence:

Port and ListenAddress in sshd_config are not used when sshd is socket-activated.

So changing Port 2222 and restarting ssh.service leaves the server listening on 22, and you conclude the config is broken. To actually change the port you edit the socket unit:

sudo systemctl edit ssh.socket
[Socket]
ListenStream=
ListenStream=2222
sudo systemctl daemon-reload
sudo systemctl restart ssh.socket

The empty ListenStream= is required - it clears the inherited value rather than adding a second port.

Whether you should change the port at all is covered further down.

2. Stop using root

If you are still logging in as root, stop:

adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

Log in as deploy, confirm sudo works, then set PermitRootLogin prohibit-password (or no) and reload.

The benefit is not that root is magically dangerous. It is that sudo leaves an audit trail and forces a deliberate pause before destructive commands.

3. A firewall - and knowing which one

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH          # or 'sudo ufw allow 2222/tcp' if you moved it
sudo ufw allow 51820/udp        # WireGuard, if this is a VPN box
sudo ufw enable
sudo ufw status verbose

Allow SSH before enabling. ufw enable takes effect immediately and does not care that you are connected over the port it is about to block.

If your VPS is on a provider with its own network firewall - Lightsail's instance firewall, DigitalOcean Cloud Firewalls, EC2 security groups - that is a separate layer that sits in front of ufw. A port has to be open in both. This is the most common reason a correctly configured service is unreachable, and the most common reason a service you thought was firewalled is not.

4. Automatic security updates

The box will outlive your interest in it. Make that safe:

sudo apt update && sudo apt upgrade -y
sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

By default this applies security updates only, which is the right scope - you want CVEs closed without a feature upgrade rebooting your VPN at 3am. If you do want automatic reboots for kernel updates, set them explicitly and pick the hour:

// /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";

Check it is actually running a week later:

sudo unattended-upgrade --dry-run --debug
cat /var/log/unattended-upgrades/unattended-upgrades.log

An unattended-upgrades install that silently stopped working is worse than not having it, because you stopped checking.

5. fail2ban or CrowdSec - with realistic expectations

sudo apt install fail2ban
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd

Here is the honest framing. Once password authentication is off, brute-force attempts against SSH cannot succeed. fail2ban is then not protecting your authentication - it is reducing log volume and CPU spent on doomed handshakes. That is worth having, but it is housekeeping, not security.

Where it earns its place is any service that still has a password: a web admin panel, a mail server, a database with an exposed port. If SSH is genuinely your only listener and it is key-only, you can skip this and lose very little.

CrowdSec is the more interesting option - it shares attacker IPs across a community feed, so you benefit from blocks other people's servers triggered. It costs more setup and more memory. On a 512 MB VPN box, that trade is not obviously worth it.

6. Should you move the SSH port?

Moving SSH to a non-standard port does not make it more secure. Anyone scanning seriously will find it, and the service on the other end is exactly as strong or weak as it was on 22.

What it does is remove nearly all of the automated noise, because commodity scanners hit 22 and move on. If you find value in logs you can actually read, that is a real benefit. Call it what it is - noise reduction, not hardening - and do not let it substitute for key-only auth.

Verify, do not assume

Run these before you close the terminal. Each one checks reality rather than intent:

sudo sshd -T | grep -iE "passwordauth|permitrootlogin|port "
sudo ufw status verbose
sudo systemctl is-enabled unattended-upgrades
ss -tulpn | grep LISTEN          # what is ACTUALLY listening

That last one deserves a moment. ss -tulpn shows every open socket and the process behind it. If something is bound to 0.0.0.0 that you did not intend to expose - a database, a metrics endpoint, a container's admin port - this is where you find out, and it is the check most people never run.

What this does not cover

Be clear about the boundary. Thirty minutes buys you: no password brute-force, no root login, nothing exposed that you did not choose, and patches applied without you.

It does not cover application vulnerabilities in whatever you are actually running, supply-chain risk in the packages you install, or anyone with access to your private key. If the box holds something that matters, the next steps are backups you have tested restoring and a second factor on SSH - not more firewall rules.

But almost nobody gets compromised by an exotic attack. They get compromised by a password-authenticating root account on a machine that has not seen apt upgrade since it was created. That is what this list is for.


Cover photo by Cong Long Vu on Unsplash.

Keep reading