Hardening

How to secure a VPS you just bought

The steps are in order of how much risk each one removes. Do only the first four and you have still removed most of what automated attacks rely on.

The short answer

Take root off SSH, use keys instead of passwords, move admin access onto a private network and deny by default at the firewall. Then automate patching, add intrusion detection with file integrity monitoring, ship logs off the box and back up somewhere that fails independently. Test a restore from that backup on a schedule. Anything beyond that list is refinement.

The eleven steps

  1. Create a non-root user and disable root SSH loginMost automated attacks try to log in as root. With root login off, most drive-by attempts fail before any of your other steps come into play.
  2. Switch SSH to key-only authenticationBots will guess passwords against SSH on a public IP around the clock. With key-only login, guessing cannot succeed at all, where other defences only slow it down.
  3. Put SSH behind a private networkA mesh VPN takes SSH off the internet entirely. That beats hardening a public SSH port, and no other single step cuts the exposure of a small server as much.
  4. Configure the firewall to deny by defaultOpen only the ports the service needs. With deny by default, a service you install and forget about later stays unreachable until you open its port.
  5. Enable unattended security updatesUnpatched packages are the most common way in. Automate patching, because on a server nobody owns, nobody patches by hand.
  6. Add rebootless kernel patchingA kernel fix normally needs a reboot, so people put it off. Live patching applies most serious kernel fixes without one, so you no longer book downtime for every fix. You still reboot, but on a date you plan instead of in a hurry.
  7. Install host intrusion detection with file integrity monitoringFile integrity monitoring tells you when a file changes that should not have. If you handle cardholder data, PCI DSS also makes it mandatory.
  8. Collect and retain logs somewhere off the machineIf the logs exist only on the compromised host, an attacker clears them first. PCI DSS Requirement 10 also obliges you to retain them.
  9. Set up monitoring that alerts a humanMetrics that nobody looks at catch nothing. An alert only helps if it reaches a person who can act on it.
  10. Configure off-site backups in a different failure domainA second disk in the same machine protects you against disk failure and nothing else. Off-site means a provider or region that fails independently of the server.
  11. Test a restore on a scheduleUntil you have restored from a backup, you do not know it works. Run a test restore on a fixed schedule and write down how long it took.

VPS hardening checklist

The steps say what to do, and this checklist proves each one is done. That needs its own check, because the setting in a config file and the setting the running service uses are often not the same. Commands assume Debian or Ubuntu. Where KernelCare, Wazuh, Netdata or restic appear, that is what we run, and the check carries over to whatever you use. On step 3, confirm SSH over the private address works before you close the public port. If your provider has a web console, find it before you start.

VPS HARDENING CHECKLIST: Debian and Ubuntu, run as root or with sudo.
Tick an item only when the command shows the expected result.

[ ] 1  Root cannot log in over SSH, and you work as a sudo user
       sshd -T | grep -i '^permitrootlogin'
         expect: permitrootlogin no
                 (the stock default prints without-password before
                 OpenSSH 10.1, prohibit-password after; both let root in
                 with a key)
       sudo -l -U youruser
         expect: the sudo rights you meant to give, and no more

[ ] 2  SSH accepts keys only
       sshd -T | grep -Ei '^(passwordauth|kbdinteractiveauth)'
         expect: both lines end in no
       grep -ri passwordauthentication /etc/ssh/sshd_config.d/
         expect: no file that says yes
       sshd -T | grep -i '^authorizedkeysfile'
         expect: you have read every file it names, for every user

[ ] 3  SSH is not reachable from the internet
       nmap -Pn -p 22 YOUR.PUBLIC.IP       (from a machine off your tailnet)
         expect: filtered or closed, never open
       tailscale ip -4                     (on the server)
         expect: ssh to this address works from your own devices

[ ] 4  Inbound traffic is denied by default
       ufw status verbose
         expect: Status: active, and Default: deny (incoming)
       nft list ruleset                    (if you manage nftables directly)
         expect: policy drop on the chain with hook input
       ss -tlnp
         expect: every listening port is one you can name
       docker ps                           (only if Docker is installed)
         expect: published ports show 127.0.0.1 unless meant to be public

[ ] 5  Security updates install themselves
       systemctl list-timers 'apt-daily*'
         expect: apt-daily.timer and apt-daily-upgrade.timer, each with
                 a NEXT time, and a LAST time once the timer has fired
                 (a new server shows - there; starting the service by
                 hand does not count)
       apt-config dump | grep Periodic
         expect: APT::Periodic::Unattended-Upgrade "1";
       unattended-upgrade -v --dry-run
         expect: "Allowed origins are:" lists a security origin
       ls -lt /var/log/unattended-upgrades/
         expect: logs written in the last day or two

[ ] 6  Kernel fixes load without waiting for a reboot
       kcarectl --status; echo $?          (KernelCare)
         expect: 0, the latest patch level is loaded
       canonical-livepatch status          (Ubuntu Livepatch)
         expect: all applicable live kernel patch modules applied
       cat /sys/kernel/livepatch/*/enabled (kernel livepatch interface)
         expect: 1 for every loaded patch; "No such file or directory"
                 means no patch is loaded at all
       ls /var/run/reboot-required         (written on kernel installs by
                                            unattended-upgrades' hook on
                                            Debian and Ubuntu, but on Ubuntu
                                            not while Livepatch is enabled)
         expect: no such file, or a reboot already booked

[ ] 7  Unexpected file changes are detected and reported
       grep ^status /var/ossec/var/run/wazuh-agentd.state
         expect: status='connected'
       (the stock agent does not watch your web root; add it first,
        see the file integrity monitoring guide)
       touch /var/www/shop/fim-canary.txt  (wait for the alert, then)
       rm /var/www/shop/fim-canary.txt
         expect: "File added to the system." and "File deleted."
                 alerts reach a person, not only a dashboard

[ ] 8  Logs leave the machine as they are written
       logger -p auth.notice -t hardening-check "off-box test"
         expect: the line appears on your log collector

[ ] 9  Monitoring alerts reach a person
       sudo su -s /bin/bash netdata
       /usr/libexec/netdata/plugins.d/alarm-notify.sh test
         (path for Netdata's own packages; Ubuntu's netdata package uses
          /usr/lib/netdata/plugins.d/, and Debian 13 has no netdata package)
         expect: the test notifications arrive on a phone someone carries

[ ] 10 Backups run, and are stored in a different failure domain
       (with your repository and password in the environment)
       restic snapshots --latest 1
         expect: a snapshot as recent as your schedule promises
       restic check --read-data-subset=5%
         expect: no errors were found
         and: the repository is with a different provider or region

[ ] 11 A restore has been tested, and timed
       time restic restore latest --target /tmp/restore-test --include /var/www/shop
       diff -r /var/www/shop /tmp/restore-test/var/www/shop
         expect: only files changed since the snapshot differ;
                 write down how long the restore took

The results below catch people out more often than the rest of the checklist.

Password login is still on after you switched it off. On Debian and Ubuntu, sshd_config includes /etc/ssh/sshd_config.d/*.conf near the top of the file, and for most settings sshd uses the first value it reads. When cloud-init applies a password setting, it writes it to 50-cloud-init.conf in that directory, so a yes there beats your no further down the main file. That is why the checklist trusts sshd -T, which prints the effective configuration, over the file. If you use Match blocks, add -C with a user and source address to see what applies to that connection.

The check finds nothing, and nothing looks like a pass. OpenSSH 10.4, released on 6 July 2026, changed the configuration dump from lower-case names to mixed case, for example PubkeyAuthentication instead of pubkeyauthentication. Debian 13, with OpenSSH 10.0p2, and Ubuntu 24.04, with 9.6p1, still print lower case, so the change hits you when you move to a release that ships 10.4 or later, or build your own. A script that greps for the old spelling without -i finds no line at all, which is easy to mistake for a clean result. Every sshd -T grep in the checklist above uses -i for that reason.

Docker bypasses ufw. Docker's documentation says Docker diverts traffic to published container ports in the nat table before it reaches the INPUT and OUTPUT chains ufw uses, and that publishing a port without a host address exposes it on every interface. A container published as 8080:80 is reachable from the internet while ufw status says deny. Publish to 127.0.0.1 and put your reverse proxy in front.

When a check fails, the fix usually has its own guide. If SSH has to stay public for a while, fail2ban vs CrowdSec covers banning the addresses that keep trying. If the timers exist but nothing gets installed, see unattended upgrades not working. Step 6 depends on your distribution, and kernel live patching compares the options. For step 7, file integrity monitoring explains why the stock configuration does not watch your web root.

The steps people skip and regret

In practice people skip log retention and restore testing, because neither shows any benefit until the day you need it. Do both on day one. After an incident it is too late, because the logs you needed have rotated away and the backup turns out to have excluded a directory nobody reviewed. Both have their own guide: backups that restore and file integrity monitoring.

Gotchas

How this was tested. On 23 September 2026 we ran the checklist commands for steps 1 to 6 in Docker containers running systemd: Debian 13 (debian:trixie; OpenSSH 10.0p2, ufw 0.36.2, nftables 1.1.3, sudo 1.9.16p2) and Ubuntu 24.04.5 LTS (OpenSSH 9.6p1, ufw 0.36.2, nftables 1.0.9, sudo 1.9.15p5), with nmap 7.94 scanning one container from the other. We checked the mixed-case claim by building OpenSSH 10.4p1 from the release tarball, the cloud-init file name against cloud-init's source, and the Docker bypass with Docker 26.1.5 running inside the Debian container. We ran the restic commands with restic 0.19.1 against a local repository and checked logger in the local journal only. Not tested: Tailscale, a real off-box log collector, KernelCare, Canonical Livepatch, /sys/kernel/livepatch, a running Wazuh agent and Netdata notifications. The container kernel has no livepatch support. For the Wazuh agent we checked its state file and rule texts in the Wazuh 4.14.0 source instead.

Sources

  1. sshd(8) and sshd_config(5), OpenBSD manual pages (the -T and -C options, first-value rule, AuthorizedKeysFile default)
  2. OpenSSH release notes, OpenSSH project, version 10.4 of 6 July 2026 (mixed-case configuration dump), with 10.1 (prohibit-password in sshd -T) and 10.5 (remaining directives made mixed case)
  3. debian-config.patch, Debian OpenSSH packaging as shipped in Ubuntu 24.04 (Include line at the top of sshd_config)
  4. ssh_util.py and cc_set_passwords.py, cloud-init source on GitHub (writes PasswordAuthentication to 50-cloud-init.conf)
  5. sudo manual, sudo project (the -l and -U options)
  6. Nmap options summary and port scanning basics, Nmap reference guide
  7. Use ufw to lock down an Ubuntu server, Tailscale documentation, last validated 5 January 2026
  8. ufw(8), Debian manual pages, and nft(8), Debian manual pages
  9. ss(8), Linux man-pages project
  10. Packet filtering and firewalls and Port publishing and mapping, Docker documentation
  11. Automatic updates, Ubuntu Server documentation
  12. systemctl(1), Linux man-pages project
  13. KernelCare Enterprise documentation, TuxCare (kcarectl --status exit codes)
  14. How to check the Livepatch Client status, Canonical Livepatch documentation
  15. ABI: /sys/kernel/livepatch, The Linux Kernel documentation
  16. Checking connection with the Wazuh server, Wazuh documentation, and rules 553 and 554, Wazuh v4.14.0 source on GitHub
  17. logger(1), Linux man-pages project
  18. Agent notifications reference, Netdata documentation (testing notifications)
  19. Working with repositories and Restoring from backup, restic documentation

Questions

How long does this take?

A couple of hours if you have done it before, and most of a day if you have not. Most of that time goes on the backup and restore testing. The first four steps take under thirty minutes and remove most of what automated attacks go after.

My host says the server is already secure. Is it?

Your host secures its platform, and the machine is yours to secure. Once you have root on a VPS, patching, firewall configuration, access control and backups are your responsibility, and shared-responsibility models say so explicitly. Check your provider's terms. The split is usually clearer than people expect.

What should a VPS hardening checklist include?

Root SSH login disabled, key-only SSH, SSH reachable only over a private network, a default-deny firewall, automatic security updates, live kernel patching, file integrity monitoring, logs shipped off the machine, alerts that reach a person, off-site backups and a timed restore test. A useful checklist also names the command that proves each item, because the setting in a file and the setting a running service uses are often different.

Why is SSH password login still enabled after I set PasswordAuthentication no?

On Debian and Ubuntu, sshd_config includes /etc/ssh/sshd_config.d/*.conf near the top, and sshd uses the first value it reads for most settings. When cloud-init applies a password setting it writes it to 50-cloud-init.conf in that directory, so a yes there beats your no further down. Run sudo sshd -T to see the effective value, fix the drop-in file, and reload sshd.

Want this done and kept done?

Wardio runs all eleven as standard, then keeps them running. See what that costs.

Free server review