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
- Completed
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.
Why this matters even more for AI servers
Section titled “Why this matters even more for AI servers”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.
Step by step
Section titled “Step by step”Work as root on your fresh server (see Creating SSH keys). Replace jij everywhere with your own username.
-
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.
-
Update everything.
Terminal window apt update && apt full-upgrade -y -
Create your own user with sudo rights.
adduserasks for a password: you’ll need it forsudolater, not for logging in.Terminal window adduser jijTerminal window usermod -aG sudo jij -
Give that user your SSH key. This copies root’s
.sshfolder, including yourauthorized_keys.Terminal window cp -r /root/.ssh /home/jij/ && chown -R jij:jij /home/jij/.ssh -
Test in a second terminal. Leave the root session open.
Terminal window ssh jij@<JOUW-SERVER-IP>Terminal window sudo whoamiAfter your sudo password:
What you should see now
rootOn Ubuntu 26.04, sudo has been replaced by sudo-rs: the prompt then reads
[sudo: authenticate] Password:and you see asterisks as you type. -
Set SSH to key-only, without root. Your own file starts with
00-, so it’s read before50-cloud-init.conf.Terminal window sudo tee /etc/ssh/sshd_config.d/00-hardening.conf >/dev/null <<'EOF'PasswordAuthentication noKbdInteractiveAuthentication noPermitRootLogin noAllowUsers jijEOF -
Check what actually applies.
sshd -tchecks the syntax,sshd -Tshows the final values.Terminal window sudo sshd -tTerminal window sudo sshd -T | grep -Ei 'passwordauthentication|permitrootlogin'What you should see now
permitrootlogin nopasswordauthentication noSee
yes? Don’t restart: first find out which file in/etc/ssh/sshd_config.d/overrides it. -
Restart SSH, with your second session still open.
Terminal window sudo systemctl restart sshThen 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). -
Turn on ufw. Allow SSH first, only then enable it.
Terminal window sudo ufw default deny incomingTerminal window sudo ufw default allow outgoingTerminal window sudo ufw allow OpenSSHTerminal window sudo ufw enableOver 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)? yFirewall is active and enabled on system startupTerminal window sudo ufw status verboseWhat you should see now
Status: activeLogging: on (low)Default: deny (incoming), allow (outgoing), disabled (routed)New profiles: skipTo Action From-- ------ ----22/tcp (OpenSSH) ALLOW IN Anywhere22/tcp (OpenSSH (v6)) ALLOW IN Anywhere (v6)The
(v6)rule is there because ufw filters IPv6 too by default (IPV6=yesin/etc/default/ufw).Running a website? Then also
sudo ufw allow 80,443/tcp. -
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.1or[::1]):Terminal window sudo ss -tlnp -
Check the automatic updates.
unattended-upgradesis on by default on Ubuntu.Terminal window cat /etc/apt/apt.conf.d/20auto-upgradesWhat you should see now
APT::Periodic::Update-Package-Lists "1";APT::Periodic::Unattended-Upgrade "1";File doesn’t exist?
sudo apt install unattended-upgradescreates it. -
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";EOFTerminal window apt-config dump | grep -i automatic-rebootWhat you should see now
Unattended-Upgrade::Automatic-Reboot "true";Unattended-Upgrade::Automatic-Reboot-Time "04:30";Set your containers to
restart: unless-stoppedand they’ll come back on their own after the reboot. -
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
jijwith your key (it’s in the snapshot), check that your data is there and delete the test server again.
Next layer: SSH not public at all
Section titled “Next layer: SSH not public at all”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:
sudo ufw allow in on tailscale0 to any port 22 proto tcpsudo ufw delete allow OpenSSHOnly 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.
fail2ban, CrowdSec or just keys?
Section titled “fail2ban, CrowdSec or just keys?”| 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
1You put PasswordAuthentication no at the bottom of sshd_config, but sshd -T says yes. How is that possible?
OpenSSH takes the first value. Put your rules in 00-hardening.conf.
2ufw blocks everything except SSH. You start n8n with -p 5678:5678. Can n8n be reached from outside?
Bind to 127.0.0.1 or use the Hetzner Cloud Firewall.
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.