Skip to content

Hardening your server

Secure a fresh Ubuntu VPS in fifteen minutes - your own user, SSH with keys only, a firewall, automatic updates and a backup you've actually restored.

In short

Hardening means: log in only with an SSH key and not as root, open only the ports you need (ufw plus the free Hetzner Cloud Firewall), automatic security updates and a tested backup. Always verify SSH with `sudo sshd -T`, because on Ubuntu `50-cloud-init.conf` can silently leave password login on. Docker opens ports past ufw, so bind containers to 127.0.0.1.

  • Intermediate
  • 8 min
  • free (on your existing VPS)
  • Fact-checked on 2 November 2026

A new server gets scanned within minutes. At Unit 42, 80% of 320 honeypots were compromised within 24 hours (2021), and one SANS honeypot counted more than 20 million SSH attempts in about 100 days (June 2026).

After this lesson you have: an Ubuntu VPS (24.04 or 26.04 LTS) you can only get into with your key, with two firewalls, automatic updates and a backup you’ve restored.

Attackers don’t scan themselves: they search indexes like Shodan for default ports. That’s how AI tools end up exposed en masse:

Tool What was exposed Source
Ollama 12,269 servers without login, 1,004 of them at Hetzner LeakIX, Feb 2026
n8n 103,476 potentially vulnerable to CVE-2025-68613 Censys, Dec 2025
OpenClaw more than 17,500 vulnerable to CVE-2026-25253 Hunt.io, Feb 2026

And it doesn’t have to be via SSH. In December 2025 a Hetzner server became a Monero miner through a vulnerable Umami container. The owner only noticed after ±10 days, thanks to an abuse email from Hetzner.

Work as root on your fresh server (see Creating SSH keys). Replace jij everywhere with your own username.

  1. Turn on the Hetzner Cloud Firewall. In the Hetzner Console: Firewalls → Create Firewall. Inbound: only TCP 22 from your own IP address, and TCP 80/443 if you run a website. Attach it to your server under Apply to. It’s free and sits outside your server. If your home IP changes later, you can get back in via the Hetzner Console.

  2. Update everything.

    Terminal window
    apt update && apt full-upgrade -y
  3. Create your own user with sudo rights. adduser asks for a password: you’ll need it for sudo later, not for logging in.

    Terminal window
    adduser jij
    Terminal window
    usermod -aG sudo jij
  4. Give that user your SSH key. This copies root’s .ssh folder, including your authorized_keys.

    Terminal window
    cp -r /root/.ssh /home/jij/ && chown -R jij:jij /home/jij/.ssh
  5. Test in a second terminal. Leave the root session open.

    Terminal window
    ssh jij@<JOUW-SERVER-IP>
    Terminal window
    sudo whoami

    After your sudo password:

    What you should see now

    root

    On Ubuntu 26.04, sudo has been replaced by sudo-rs: the prompt then reads [sudo: authenticate] Password: and you see asterisks as you type.

  6. Set SSH to key-only, without root. Your own file starts with 00-, so it’s read before 50-cloud-init.conf.

    Terminal window
    sudo tee /etc/ssh/sshd_config.d/00-hardening.conf >/dev/null <<'EOF'
    PasswordAuthentication no
    KbdInteractiveAuthentication no
    PermitRootLogin no
    AllowUsers jij
    EOF
  7. Check what actually applies. sshd -t checks the syntax, sshd -T shows the final values.

    Terminal window
    sudo sshd -t
    Terminal window
    sudo sshd -T | grep -Ei 'passwordauthentication|permitrootlogin'

    What you should see now

    permitrootlogin no
    passwordauthentication no

    See yes? Don’t restart: first find out which file in /etc/ssh/sshd_config.d/ overrides it.

  8. Restart SSH, with your second session still open.

    Terminal window
    sudo systemctl restart ssh

    Then open a third terminal and log in again as jij. If that works, try root:

    Terminal window
    ssh root@<JOUW-SERVER-IP>

    What you should see now

    root@<JOUW-SERVER-IP>: Permission denied (publickey).
  9. Turn on ufw. Allow SSH first, only then enable it.

    Terminal window
    sudo ufw default deny incoming
    Terminal window
    sudo ufw default allow outgoing
    Terminal window
    sudo ufw allow OpenSSH
    Terminal window
    sudo ufw enable

    Over SSH, ufw always asks for confirmation. Type y: you’ve just allowed OpenSSH.

    What you should see now

    Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
    Firewall is active and enabled on system startup
    Terminal window
    sudo ufw status verbose

    What you should see now

    Status: active
    Logging: on (low)
    Default: deny (incoming), allow (outgoing), disabled (routed)
    New profiles: skip
    To Action From
    -- ------ ----
    22/tcp (OpenSSH) ALLOW IN Anywhere
    22/tcp (OpenSSH (v6)) ALLOW IN Anywhere (v6)

    The (v6) rule is there because ufw filters IPv6 too by default (IPV6=yes in /etc/default/ufw).

    Running a website? Then also sudo ufw allow 80,443/tcp.

  10. Bind Docker ports to 127.0.0.1. Docker opens published ports past ufw. So in your docker-compose.yml, don’t write "5678:5678" but:

    ports:
    - "127.0.0.1:5678:5678"

    Check what’s listening publicly (anything not on 127.0.0.1 or [::1]):

    Terminal window
    sudo ss -tlnp
  11. Check the automatic updates. unattended-upgrades is on by default on Ubuntu.

    Terminal window
    cat /etc/apt/apt.conf.d/20auto-upgrades

    What you should see now

    APT::Periodic::Update-Package-Lists "1";
    APT::Periodic::Unattended-Upgrade "1";

    File doesn’t exist? sudo apt install unattended-upgrades creates it.

  12. Let the server reboot itself after a kernel update, at night at 04:30 (in your server’s time zone; check it with timedatectl).

    Terminal window
    sudo tee /etc/apt/apt.conf.d/52reboot >/dev/null <<'EOF'
    Unattended-Upgrade::Automatic-Reboot "true";
    Unattended-Upgrade::Automatic-Reboot-Time "04:30";
    EOF
    Terminal window
    apt-config dump | grep -i automatic-reboot

    What you should see now

    Unattended-Upgrade::Automatic-Reboot "true";
    Unattended-Upgrade::Automatic-Reboot-Time "04:30";

    Set your containers to restart: unless-stopped and they’ll come back on their own after the reboot.

  13. Turn on backups and test a restore. Hetzner Console → Backups → Enable Backups → pick your server → Enable & Buy now (daily, 7 rotating copies). Also take a snapshot via Snapshots → Take Snapshot. Then Add server and pick your snapshot under Image. Log in as jij with your key (it’s in the snapshot), check that your data is there and delete the test server again.

With Tailscale (next lesson) you log in via your own private network. Does ssh jij@<tailscale-naam> work? Then remove port 22 from the Hetzner firewall and restrict ufw to the Tailscale interface:

Terminal window
sudo ufw allow in on tailscale0 to any port 22 proto tcp
Terminal window
sudo ufw delete allow OpenSSH

Only do this once the Tailscale login works, or you’ll lock yourself out. Keep Tailscale up to date: 17 security bulletins have already appeared in 2026, six of them about Tailscale SSH.

Option What it does Our verdict
Keys only + SSH not public Guessing is hopeless, scanners see nothing The foundation. This does the real work.
fail2ban Blocks IPs after failed attempts, with local knowledge Nice to have: less noise in your logs, no extra protection with key-only.
CrowdSec Same, plus a shared community blocklist Only worthwhile if you run public web services. It is an extra daemon, though.

Never use fail2ban as a reason to leave password login on.

Checklist

0/7

Your progress is only stored in this browser.

Test yourself

  1. 1You put PasswordAuthentication no at the bottom of sshd_config, but sshd -T says yes. How is that possible?

  2. 2ufw blocks everything except SSH. You start n8n with -p 5678:5678. Can n8n be reached from outside?

  3. 3What do you do before restarting SSH after a config change?

Next lesson: security check for your app. Following the learning path Your own 24/7 agent? Then Tailscale comes next, so port 22 can be closed completely.