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
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
#cloud-config users: - name: holu groups: users, admin sudo: ALL=(ALL) NOPASSWD:ALL shell: /bin/bash ssh_authorized_keys: - <public_ssh_key> package_update: true package_upgrade: true packages: - fail2ban - ufw - ca-certificates - curl write_files: - path: /etc/ssh/sshd_config.d/ssh-hardening.conf content: | PermitRootLogin no PasswordAuthentication no Port 2222 KbdInteractiveAuthentication no MaxAuthTries 2 AllowTcpForwarding no X11Forwarding no AllowAgentForwarding no AuthorizedKeysFile .ssh/authorized_keys AllowUsers holu - path: /etc/fail2ban/jail.local content: | [sshd] enabled = true port = 2222 runcmd: # Firewall: open the new SSH port before enabling ufw - ufw allow 2222/tcp - ufw --force enable # Fail2ban (distro defaults handle the journal backend and nftables) - systemctl enable --now fail2ban # Docker: official apt repository, as in the current Docker docs - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc - chmod a+r /etc/apt/keyrings/docker.asc - | cat > /etc/apt/sources.list.d/docker.sources <<EOF Types: deb URIs: https://download.docker.com/linux/ubuntu Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") Components: stable Architectures: $(dpkg --print-architecture) Signed-By: /etc/apt/keyrings/docker.asc EOF - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - systemctl enable --now docker - usermod -aG docker holu - reboot |
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:
|
1 |
sudo fail2ban-client status sshd |
4. Small cleanups
ufw --force enableinstead ofufw enable, so it never waits for a “y” that nobody will type.ChallengeResponseAuthenticationis just the deprecated alias ofKbdInteractiveAuthentication, so one line is enough.systemctl enable --nowinstead 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-USERchain, not into ufw.
And if you do run a reverse proxy, open its ports in ufw too:
|
1 2 |
- ufw allow 80/tcp - ufw allow 443/tcp |
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:
|
1 |
ssh-keygen -t ed25519 -C "holu@hetzner-docker" |
- The comment (
-C) is only a label for you. An email address is tradition, butuser@serveror something likehetzner-docker-2026is more useful when you manage many keys. It has no security impact. - Recent OpenSSH versions default to Ed25519 anyway. Plain
ssh-keygenwithout-tgives you an Ed25519 key. Being explicit doesn’t hurt. -ois 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 seessh-keygen -o -a 256 -t ed25519in 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-skkeeps 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:
|
1 |
ssh-copy-id -i ~/.ssh/id_ed25519.pub -p 2222 holu@<server-ip> |
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.