Linux Server Hardening Checklist for a Fresh VPS

It is 2 a.m. and you have just rebuilt a box. The provider’s Debian 13 image booted, you have a root password in your inbox, port 22 is listening on every interface, and by the time you finish this sentence a scanner somewhere has already tried root with a handful of common passwords. A fresh VPS is not a blank slate – it is an exposed one. The window between “first boot” and “hardened” is exactly when boxes get compromised, so this is the checklist I run on every new server, in the order that avoids locking myself out halfway through.

Survey before you change anything

Do not start editing config until you know what the provider already did to the image. Most cloud images ship with opinions baked in, and fighting them blind is how you end up with two conflicting SSH configs.

cat /etc/os-release
ip -br addr
ss -tulpn
systemctl list-units --type=service --state=running
dpkg -l | grep -E 'ufw|fail2ban|unattended-upgrades|ssh'

Read ss -tulpn carefully. Anything bound to 0.0.0.0 or :: is reachable from the internet unless something in front of it blocks it. Anything on 127.0.0.1 is not, and that distinction is the whole game.

Two things routinely surprise people here. First, the provider’s web console “firewall” is a separate layer from the host’s own rules – a port closed in the panel is still worth closing on the host, and a port open in the panel is still reachable even if you never touched ufw. Second, cloud images often run a cloud-init-created admin user with full sudo and a key you did not install. Check /home and getent passwd before assuming root is your only account.

Keys first, then take the password away

Log in and put your own key in place before disabling anything. Use Ed25519, not RSA:

ssh-keygen -t ed25519 -a 100 -C "muzza@workstation"
ssh-copy-id -i ~/.ssh/id_ed25519.pub admin@server

Now prove the key works in a second terminal, with password auth explicitly refused, before you change a single server setting:

ssh -o PreferredAuthentications=publickey -o PasswordAuthentication=no admin@server

Now the trap that catches almost everyone on Debian. The stock sshd config carries Include /etc/ssh/sshd_config.d/*.conf as its first line. In OpenSSH the first value of a keyword wins, and drop-in includes are read at the point they appear. So a file like /etc/ssh/sshd_config.d/50-cloud-init.conf containing PasswordAuthentication yes silently overrides anything you add lower down in the main file. Editing the main config looks correct, changes nothing, and you keep seeing password attempts in the journal.

Check the effective config rather than the file you think you edited:

sshd -T | grep -Ei 'passwordauthentication|permitrootlogin|kbdinteractive'

Fix it by dropping your own file, numbered to sort last, into the same drop-in directory. Name it 99-hardening.conf with these three lines:

PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
sudo sshd -t && sudo systemctl restart ssh

sshd -t is not optional. A config with a typo takes SSH down on restart, and if that was your only way in you are now on the provider’s out-of-band console. Keep your existing session open until a new login succeeds.

Note the difference between prohibit-password and no for PermitRootLogin. prohibit-password still allows key-based root logins, which is what you want while you set up a non-root admin account. Switch to no once that account exists and is tested. Setting it to no on a box where your only key belongs to root is the classic self-lockout.

Close the ports you did not know were open

sudo apt install ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status verbose

Run ufw allow OpenSSH before ufw enable. UFW warns you that it may disrupt existing connections, and that warning is real if your SSH port is not yet allowed. If sshd listens on a non-standard port, allow the number explicitly – the OpenSSH app profile only knows port 22.

The trap that bites: UFW and Docker

This is the piece competing checklists get wrong, and it silently undoes everything above. Docker does not use the host firewall. When a container publishes a port, the daemon writes its own DNAT and forwarding rules directly into iptables, and those rules are evaluated before UFW’s chains. The result:

docker run -d -p 8080:80 nginx
sudo ufw deny 8080
sudo ufw status        # shows "deny 8080" and looks correct

The container is still reachable at http://your-server:8080 from anywhere on the internet. The UFW rule you just wrote is never consulted for that traffic, and nothing in ufw status or on the host gives it away. You have to test from outside:

nmap -Pn -p 8080 your-server
curl -sS --max-time 5 http://your-server:8080 | head -n 3

Audit what is actually published instead of what you remember publishing:

docker ps --format '{{.Names}}\t{{.Ports}}'

Any mapping starting 0.0.0.0: is world-reachable. The clean fix is to bind published ports to loopback and put a reverse proxy in front:

docker run -d -p 127.0.0.1:8080:80 nginx

Now only the host’s own nginx can reach the container. The ufw-docker project is the alternative when you genuinely need UFW rules to govern container ports, but loopback binding plus a proxy has far fewer moving parts and cannot be silently defeated by a daemon reload.

Automatic security updates – and the reboot nobody applies

sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

On Debian 13 there is no unattended-upgrades.timer, and its absence is not a fault. Upgrades are driven by apt-daily.timer and apt-daily-upgrade.timer. Keep local policy in a file that sorts after the stock one – /etc/apt/apt.conf.d/52unattended-upgrades-local – so it wins:

Unattended-Upgrade::Allowed-Origins {
        "${distro_id}:${distro_codename}-security";
};

Prove the policy parsed rather than trusting the file you wrote:

sudo unattended-upgrade --dry-run --debug | grep -i "Allowed origins are:"

The debug line names the permitted origins explicitly. If it lists more than the security pocket, your file did not win.

Then deal with the part everyone ignores. A host that has been silently applying security updates for six months without restarting is running a kernel with known holes, and none of that shows up in apt list --upgradable:

cat /var/run/reboot-required.pkgs
uname -r
dpkg -l | grep linux-image

If the running kernel is not the newest installed linux-image, you have a pending reboot. Either reboot on your own schedule, or set a maintenance window in the same local config with Unattended-Upgrade::Automatic-Reboot "true" and Automatic-Reboot-Time "03:00". Do not set Unattended-Upgrade::Mail unless an MTA is present – check with dpkg -l | grep -iE 'postfix|exim|sendmail'. With no MTA the setting is inert at best, and a bad recipient can stall the whole apt transaction at worst.

fail2ban that actually bans

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

On Debian 12 and 13 the stock sshd jail matches nothing, while happily reporting itself as active and banning zero people. The cause is the journal match. The shipped config pins _COMM=sshd, but modern OpenSSH forks a per-connection sshd-session process, so every auth failure is tagged _COMM=sshd-session. Confirm it against real events instead of guessing at the process name:

sudo journalctl -u ssh --since "-10 min" -o json | grep -m1 '_COMM'

Override it in /etc/fail2ban/jail.local, which loads after jail.conf and jail.d/*.conf and therefore wins:

[sshd]
enabled  = true
port     = 22
mode     = aggressive
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd + _COMM=sshd-session
sudo fail2ban-client -t && sudo systemctl restart fail2ban
sudo fail2ban-client status sshd

The Journal matches: line in that output must now show _COMM=sshd + _COMM=sshd-session. If it still shows only _COMM=sshd, your override did not load, no matter what the file says. Effective values come from the daemon, not the file: fail2ban-client get sshd bantime. Restarting with a working filter usually bans a real internet scanner straight out of the journal backlog, which is the best end-to-end proof you can get.

Reduce the surface, not just the ports

Create the non-root admin account, give it sudo, test its key, and only then set PermitRootLogin no. Then turn off what you are not using:

systemctl list-unit-files --state=enabled
systemctl list-units --type=service --state=active
getent passwd | awk -F: '$3 >= 1000 {print $1}'

Cloud images carry services you will never touch. Every listening daemon is another chance to be the one with the unpatched package.

On kernel sysctl tuning, resist the urge to paste a fifty-line “hardening” list. Most of it is cargo cult and some of it breaks things – disabling ICMP echo breaks path-MTU discovery and produces mysterious hangs on large transfers, while aggressive TCP settings fight the provider’s network. The sysctls worth setting on a fresh box are three: reverse-path filtering with net.ipv4.conf.all.rp_filter=1, dropping source-routed packets with net.ipv4.conf.all.accept_source_route=0, and refusing redirects with net.ipv4.conf.all.accept_redirects=0. Put them in /etc/sysctl.d/99-hardening.conf and apply with sysctl --system. Everything beyond that should earn its place by fixing a problem you actually have.

The order that keeps you from locking yourself out

  1. Survey the image. Learn every account and every listening port before changing anything.
  2. Install your Ed25519 key and prove it works with password auth refused.
  3. Disable password auth via a numbered drop-in, validate with sshd -t, reload, and open a fresh session before closing the old one.
  4. Set UFW to deny incoming, allow SSH, enable it, and verify with verbose status.
  5. Audit every published Docker port from outside the host – never from the host.
  6. Configure security-only unattended upgrades and check for a pending kernel reboot.
  7. Fix the fail2ban journal match and confirm the effective match with the daemon query.
  8. Create the non-root admin account, then tighten root login further.
  9. Only now add the handful of sysctls and disable the services you genuinely do not need.

The short version

  • Keys before passwords, and prove the key in a second terminal before you disable anything.
  • Debian’s SSH drop-ins are included first and win – check sshd -T, not the file you edited.
  • Allow SSH in UFW before enabling it, or you are on the provider’s console.
  • UFW does not control Docker. Published container ports bypass it entirely; bind them to 127.0.0.1 and proxy in front.
  • Security updates without a reboot leave a vulnerable kernel running. Check /var/run/reboot-required.pkgs and uname -r.
  • The stock fail2ban sshd jail on Debian 12/13 matches nothing. Fix journalmatch and verify with fail2ban-client status sshd.
  • Skip the fifty-line sysctl copy-paste. Set rp_filter, source-route and redirect rules, and leave the rest alone.

Leave a Reply

Your email address will not be published. Required fields are marked *