Provision and harden a Hetzner VPS
Create a small Hetzner Cloud CX22 running Debian 12, get key-only SSH access as a non-root user, lock down sshd, and put two firewalls in front of it — opening only the ports the edge will actually use.
In this series
Expose your homelab to the internetlevel:Intermediate
This is Part 3 of Expose your homelab to the internet. In Part 2 you bought a domain and pointed DNS at a placeholder IP. Now we stand up the machine that IP belongs to: a small, always-on, public Linux host that will become the edge of your homelab. By the end of this post it exists, you can SSH into it as a non-root user with a key, root login is off, password login is off, security updates apply themselves, and a firewall lets in only the four ports the rest of the series needs.
ℹ️ Note — This is the same hardening philosophy as Hardening a fresh Debian server, applied to a public box. If you have done that post, none of this is new — but a public VPS is scanned within minutes of booting, so the order matters even more. If you want the deeper "why" behind each step, read that post alongside this one.
What a VPS is and why Hetzner
What a VPS actually is
A VPS — virtual private server — is a slice of a physical server in a datacentre, sold to you as if it were a whole machine: its own CPU, RAM, disk, and crucially its own public IPv4 address. That public IP is the entire point. Your home connection sits behind your ISP's NAT and a router you do not fully control; a VPS is a host on the open internet that you do control, reachable from anywhere. It is the public face we will hide your homelab behind.
Why Hetzner, and why the small one
You do not need a powerful machine for this. The edge will run a reverse proxy (Caddy) and one end of a WireGuard tunnel — both are featherweight. Hetzner Cloud is a German provider with very cheap, very reliable cloud instances, and their CX22 plan (2 vCPU, 4 GB RAM, 40 GB disk, ~€4/month) is more than enough headroom for an edge proxy plus tunnel. You could run this on any provider — DigitalOcean, Vultr, Linode — the commands are identical; I use Hetzner because it is cheap and the console is clean.
💡 Tip — When you create the server, pick a datacentre location near you (Hetzner offers Nuremberg, Falkenstein, Helsinki, and others). Every byte between you and your homelab traverses this box, so lower latency to it means a snappier connection to your own services.
Create the server
Everything in this section happens in the Hetzner Cloud Console — a web UI. Walking through it:
- Create a project. A project is just a folder for related servers and resources. Name it something like
homelab-edge. - Add your SSH public key first. In the project, go to Security → SSH keys → Add SSH key and paste your public key. Doing this before creating the server means Hetzner injects the key automatically and you never deal with a root password.
- Create a server. Click Add Server and choose:
- Location — the datacentre nearest you.
- Image — Debian 12 (bookworm). This is what the rest of the series assumes.
- Type — CX22 (shared vCPU, Intel/AMD — the cheapest is fine).
- SSH keys — tick the key you added in step 2.
- Firewalls — create and attach a Hetzner Cloud Firewall now (more on this below). You can also do it after; we will configure it in the firewall section.
- Create & Buy now. The server boots in a few seconds and the console shows its public IPv4 address.
Throughout this post that address is the placeholder 203.0.113.10 — substitute your real one everywhere you see it.
Generating an SSH key if you do not have one
Step 2 above assumes you have an SSH key. If you do not, generate one on your laptop (never on the server) before creating the VPS:
On your laptop
ssh-keygen -t ed25519 -C "your-email@example.com"
Press Enter to accept the default path (~/.ssh/id_ed25519) and set a passphrase when prompted — that passphrase encrypts the private key on disk. This creates two files: id_ed25519 (the private key, which never leaves your laptop) and id_ed25519.pub (the public key, which is what you paste into Hetzner). The contents of the .pub file are what go in step 2.
ℹ️ Note — An SSH key pair is a secret you have, not one you type. The private half proves your identity without ever crossing the network, so there is nothing for a scanner to brute-force. That is why the very next thing we do is turn passwords off entirely.
First login and a non-root user
The server boots with only root, and Hetzner has already placed your public key on it. Log in:
On your laptop
ssh root@203.0.113.10
If it lets you in with no password prompt, key auth is working. Logging in as root for everyday work is a bad habit — every command runs with unlimited power and nothing records intent. So the first thing we do is create a regular user who can borrow root powers per-command with sudo.
On the VPS (as root) — create the user and add it to the sudo group:
adduser mart
usermod -aG sudo mart
adduser will prompt for a password — set one; it is the fallback for sudo, not for SSH login. Replace mart with whatever username you like throughout.
Now copy your SSH key from root to the new user so you can log in directly as that user. rsync preserves ownership and permissions in one step:
On the VPS (as root) — copy the authorised keys to the new user:
rsync --archive --chown=mart:mart ~/.ssh /home/mart
Open a new terminal and confirm you can log in as the new user with your key, and that sudo works:
On your laptop (new terminal)
ssh mart@203.0.113.10
sudo whoami # should print: root
If both work, you are done with root. Keep this mart session open — we are about to edit SSH config, and an open session is your safety net.
SSH hardening
What we are changing and why
Right now the box accepts key login as mart, but it also still accepts root login and (in principle) password login. Those are the two doors scanners hammer on. We close both with a drop-in config file. Debian's sshd reads any *.conf file in /etc/ssh/sshd_config.d/, so we add our own rather than editing the main file — cleaner, and it survives package upgrades.
Create the drop-in:
/etc/ssh/sshd_config.d/10-hardening.conf
# Key-only authentication; no root, no passwords
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
What each line does:
PermitRootLogin no— root can no longer SSH in at all. You log in asmartandsudoup.PasswordAuthentication no— passwords are rejected outright, so guessing them is pointless. Only keys work.PubkeyAuthentication yes— explicitly keep key auth on (it is the default, but being explicit means a future change can't silently disable it).
Apply it by restarting the SSH service:
On the VPS (as mart)
sudo systemctl restart ssh
⛔ Danger — Do not close your current session after this restart. Open a new terminal and confirmssh mart@203.0.113.10still logs you in. If the new session works, you are safe to close the old one. If it does not — for example your key wasn't copied correctly in the previous step — your still-open session can undo the change (sudo rm /etc/ssh/sshd_config.d/10-hardening.confthen restartsshagain). Once you close every session with a broken config, you are locked out and the only recovery is Hetzner's web console. Always test a new session first.
Updates
A public host is only as safe as its newest patches. Bring it fully current, then make patching automatic.
On the VPS (as mart) — update the package index and upgrade everything:
sudo apt update && sudo apt full-upgrade -y
apt update refreshes the list of available packages; apt full-upgrade installs the newest versions, including ones that need to add or remove dependencies (a plain upgrade won't). If the kernel is upgraded, reboot when convenient.
You will not log in every day to run this, so let the box patch its own security holes. Debian's unattended-upgrades does exactly that:
On the VPS (as mart) — install and enable automatic security updates:
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
The dpkg-reconfigure step shows a single dialog — choose Yes — which writes the config that enables daily security updates. From now on the box installs security patches on its own.
💡 Tip — unattended-upgrades applies security updates by default and leaves everything else alone, so it almost never breaks anything. That is the right trade for an edge host: you want it patched even on the weeks you forget it exists.Firewall: two layers
A public box should refuse every connection except the handful you actually want. We do this in two layers, and that redundancy is deliberate:
- The Hetzner Cloud Firewall runs at the network edge, before traffic ever reaches your server. Blocked packets never touch the box at all — and it keeps working even if you misconfigure something inside the VM.
- The host firewall (
ufw) runs on the server. It is the rule you control entirely from the command line, and the one that protects you if the cloud firewall is ever detached or misconfigured.
Two locks, two keyholders. If one is wrong, the other still holds.
Which ports, and why
Both firewalls open exactly the same four ports — nothing else:
flowchart LR
NET["Internet"] -->|"22/tcp<br/>SSH (you)"| VPS["Hetzner VPS<br/>203.0.113.10<br/><i>Debian 12</i>"]
NET -->|"80/tcp<br/>HTTP → HTTPS redirect"| VPS
NET -->|"443/tcp<br/>HTTPS (Caddy)"| VPS
NET -->|"51820/udp<br/>WireGuard tunnel"| VPS
NET -.->|"everything else<br/>DENY"| X(("✗"))
Only four doors are open. SSH is for you; 80 and 443 are for Caddy's web traffic; 51820/udp is the WireGuard tunnel to your home. Every other port is dropped.
- 22/tcp — SSH, so you can keep managing the box.
- 80/tcp — HTTP. Caddy (Part 5) uses this for the HTTP→HTTPS redirect and for Let's Encrypt certificate challenges.
- 443/tcp — HTTPS. The real front door for your web services, terminated by Caddy.
- 51820/udp — WireGuard (Part 4). This is where the encrypted tunnel from your home server connects.
Caddy and WireGuard arrive in later parts; we open their ports now so we don't have to touch the firewall again later. Until then those ports simply have nothing listening behind them, which is harmless.
The Hetzner Cloud Firewall
In the console, edit the firewall you attached at creation (Firewalls → your firewall → Rules). Add four inbound rules — protocol/port for each, source Any IPv4 (and Any IPv6 if you use it):
| Port | Protocol | Purpose |
|---|---|---|
| 22 | TCP | SSH |
| 80 | TCP | HTTP (Caddy) |
| 443 | TCP | HTTPS (Caddy) |
| 51820 | UDP | WireGuard |
Outbound traffic can stay fully allowed — the box needs to reach the internet for updates and certificates. Anything not matching an inbound rule is dropped automatically.
💡 Tip — If your home connection has a stable IP, you can tighten the SSH rule (port 22) to just that source in the cloud firewall. With a dynamic home IP it is simpler to leave SSH open to Any and rely on key-only auth — which we already enforced.The host firewall (ufw)
ufw ("uncomplicated firewall") is a friendly front-end to the kernel's packet filter. Install it, then write the rules. The single most important detail: allow SSH before you enable the firewall, or enabling it will cut your own connection.
⚠️ Warning —ufwdefaults to denying all incoming traffic the moment it is enabled. If you runufw enablebefore adding the SSH rule, your active session dies and you cannot reconnect. Add the22/tcprule first. Keep a second session open as well, exactly as you did for SSH hardening.
Install ufw and define the policy and allowed ports as one logical block — note the SSH allow comes first:
On the VPS (as mart)
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH — must come first
sudo ufw allow 80/tcp # HTTP (Caddy, Part 5)
sudo ufw allow 443/tcp # HTTPS (Caddy, Part 5)
sudo ufw allow 51820/udp # WireGuard (Part 4)
default deny incoming is the safe baseline — block everything not explicitly allowed. default allow outgoing lets the box fetch updates and certificates. The four allow lines open exactly the doors from the diagram.
Only once SSH is allowed, turn the firewall on:
On the VPS (as mart)
sudo ufw enable
It warns that this may disrupt existing connections — type y. Because SSH is already allowed, your session survives. Confirm the rule set:
On the VPS (as mart)
sudo ufw status verbose
You should see exactly the four ports allowed and the default-deny policy on incoming. As before, open a fresh terminal and confirm you can still SSH in before you walk away.
What comes later, not here
You may be expecting fail2ban or CrowdSec — automated banning of repeated failed logins — and the deeper hardening that goes with a long-lived public host. That belongs in Part 7 of this series, where we harden the edge as a whole, and skipping it here keeps things from being duplicated. With key-only SSH, no root login, no passwords, automatic updates, and a default-deny firewall, this box is already a hard target. The brute-forcers will knock; there is simply nothing for them to push on.
Where this leaves you
You now have a public, hardened Debian 12 host at 203.0.113.10: reachable only by your SSH key as a non-root user, patching itself, and firewalled down to four purposeful ports. It is the solid foundation the rest of the edge gets built on — but right now those ports 443 and 51820 lead nowhere. Next we build the private link: WireGuard tunnel: VPS to homelab connects this public box to your home server over an encrypted tunnel, without ever opening a port on your home router. Back to the series hub any time.