A hardened Hetzner Cloud server with Docker in one cloud-init file (2026 edition)

When I spin up a new Hetzner Cloud VM, I paste a cloud-init file into the “Cloud config” box and let the server set itself up. Hetzner’s community tutorial has a nice baseline: a sudo user, SSH moved to port 2222, root and password logins disabled, fail2ban and ufw. What it doesn’t have is Docker. This post is the version I actually use, plus a few things I corrected after checking it against current documentation.

The starting point

The baseline file creates a user holu with an SSH key, drops an sshd_config.d hardening snippet, writes a fail2ban jail and enables ufw with port 2222 open. That’s a solid base. I wanted to add Docker Engine and the Compose plugin without having to SSH in afterwards and do it by hand.

The final cloud-init file

Replace <public_ssh_key> with your public key, pick Ubuntu 24.04 or 26.04 as the image, and that’s it. After the reboot, connect with ssh -p 2222 holu@<server-ip> and docker compose version works without sudo.

What I changed compared to my first draft

My first version used the Docker install recipe that’s been floating around for years. Checking it against the current docs turned up a few things worth fixing.

1. Docker’s repo setup has a new format

The old recipe dearmored the GPG key into /etc/apt/keyrings/docker.gpg and wrote a one-line docker.list using lsb_release -cs. Docker’s official Ubuntu guide now downloads the ASCII key as docker.asc and writes a deb822-style docker.sources file, reading the codename from /etc/os-release. That also means gnupg, lsb-release and apt-transport-https no longer need to be in the package list. The old way still works, but there’s no reason to start a new server with it.

Supported LTS releases are currently 22.04, 24.04 and 26.04.

2. Don’t add the user to a group that doesn’t exist yet

I originally put docker into the user’s groups: line. The problem: users are created early in the boot, long before the Docker package (which creates the docker group) is installed. Depending on the cloud-init version that either warns or creates a non-system group that the package then reuses. The cleaner way is to leave it out and run usermod -aG docker holu after the install, which the file already does.

3. Let fail2ban use the distro defaults

The Hetzner baseline wrote a jail.local with banaction = iptables-multiport. On Ubuntu 24.04 and 26.04 the packaged defaults-debian.conf already sets the journal (systemd) backend and an nftables ban action, and on 26.04 it even matches the right unit (ssh.service). Forcing iptables-multiport overrides that for no gain. My jail.local now only says “enable sshd on port 2222” and leaves the rest to the distro. I also moved it into write_files instead of a printf one-liner, which is easier to read.

Check it after boot with:

4. Small cleanups

  • ufw --force enable instead of ufw enable, so it never waits for a “y” that nobody will type.
  • ChallengeResponseAuthentication is just the deprecated alias of KbdInteractiveAuthentication, so one line is enough.
  • systemctl enable --now instead of separate enable/start lines.

5. The custom SSH port and socket activation

Since Ubuntu 22.10, SSH is socket-activated (ssh.socket). On a running system, changing Port means a systemctl daemon-reload and a socket restart, not just restarting sshd. In this file it doesn’t matter, because the final reboot regenerates the socket config from sshd_config. But if you ever change the port later by hand, remember that.

The big gotcha: Docker bypasses ufw

This one isn’t new, but it catches everyone at least once. When you publish a container port (ports: - "8080:80"), Docker writes its own iptables rules that are evaluated before ufw’s. The port is reachable from the internet even though ufw says it’s closed. Docker’s own install page puts this right at the top under “Firewall limitations”.

What I do about it:

  • Bind to localhost for everything that sits behind a reverse proxy: ports: - "127.0.0.1:8080:80". Only the proxy publishes 80/443.
  • Use the Hetzner Cloud Firewall in addition to ufw. It filters before traffic ever reaches the VM, so Docker can’t punch holes in it. Allow 2222, 80 and 443 there and nothing else.
  • If you really need host-level filtering of container ports, rules go into the DOCKER-USER chain, not into ufw.

And if you do run a reverse proxy, open its ports in ufw too:

A side effect of AllowTcpForwarding no

The hardening snippet disables TCP forwarding. That’s good practice, but it also means you can’t do ssh -L 5001:127.0.0.1:5001 to reach a localhost-bound admin UI like Dockge. If you rely on SSH tunnels for that, either allow forwarding for your user with a Match User holu block, or put the UI behind your VPN instead.

Generating the SSH key (2026)

For the key you paste into the file, Ed25519 is still the right choice:

  • The comment (-C) is only a label for you. An email address is tradition, but user@server or something like hetzner-docker-2026 is more useful when you manage many keys. It has no security impact.
  • Recent OpenSSH versions default to Ed25519 anyway. Plain ssh-keygen without -t gives you an Ed25519 key. Being explicit doesn’t hurt.
  • -o is obsolete. The OpenSSH key format has been the default since OpenSSH 7.8, and current man pages don’t even list the flag anymore. You’ll still see ssh-keygen -o -a 256 -t ed25519 in old guides.
  • -a (KDF rounds) only matters if you set a passphrase. It makes brute-forcing a stolen private key slower. Older OpenSSH used 16 rounds by default; current OpenSSH uses 32. Going higher (e.g. -a 100) is fine for a laptop key you unlock once per session via the agent.
  • Hardware keys: -t ed25519-sk keeps the private key on a FIDO token like a YubiKey. The newest OpenSSH also has a post-quantum hybrid type (mldsa44-ed25519), but it only helps once your servers’ OpenSSH supports it too, so for a Hetzner box today Ed25519 is the safe pick.

Copy the key to the server later with:

Summary

Topic Old habit 2026
Docker apt key docker.gpg via gpg --dearmor docker.asc downloaded as-is
Docker repo file one-line docker.list deb822 docker.sources
docker group in cloud-init groups: usermod -aG docker after install
fail2ban iptables-multiport, auth.log distro defaults: journal + nftables
Firewall trust ufw ufw + Hetzner Cloud Firewall, bind containers to 127.0.0.1
SSH key ssh-keygen -o -a 256 -t ed25519 ssh-keygen -t ed25519 -C "label"

One file, one reboot, and you have a hardened Docker host ready for docker compose up -d.

Leave a comment