Appearance
Firewall
The box has two firewall layers inside it, ufw and the DOCKER-USER chain, and one outside it, Hetzner's Cloud Firewall. This page explains why each exists. The short version: with Docker, ufw alone does not protect you.
Docker bypasses ufw
Publishing a container port ignores ufw
When a container publishes a port (-p 5432:5432, or ports: in a compose file), Docker writes its own iptables rules into the nat and FORWARD chains, and those rules are evaluated before ufw's. So ufw deny 5432 plus a container started with -p 5432:5432 gives you a database open to the whole internet, while ufw status reports the port as closed.
This is not obscure. It is how many hobby boxes get compromised, and it does not show up in any of the tools people usually check.
DOCKER-USER: the real container firewall
Docker leaves exactly one hook that it consults first and never rewrites: the DOCKER-USER chain. deploy/firewall.sh builds the container firewall there. It is idempotent, so you can re-run it any time:
bash
sudo ./deploy/firewall.shWhat it does:
Finds the public interface, the one carrying the default route.
ufw (host traffic), if installed: resets ufw, denies incoming by default, allows outgoing, allows each port in
ports.conf, and enables ufw. If ufw is not installed, it says so and continues. An earlier version stopped here, which meant a missing convenience blocked the rule that actually matters.DOCKER-USER (container traffic): flushes the chain and rebuilds it:
bashiptables -A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN iptables -A DOCKER-USER ! -i "$WAN" -j RETURN iptables -A DOCKER-USER -i "$WAN" -p <proto> --dport <port> -j RETURN # per ports.conf entry iptables -A DOCKER-USER -i "$WAN" -j DROP- Established connections keep working, including the SSH session you are running it from and every live match's WebSocket.
- Traffic that did not come from the internet (Docker-internal, loopback) is handed back to Docker's own rules.
- Ports listed in
ports.confare allowed. - Everything else arriving from the internet at a container is dropped, whatever any compose file has published.
Saves the rules with
netfilter-persistent, if it is available.
Docker restarts wipe the rule
Docker rebuilds its chains when it restarts, and that removes the DROP rule. setup.sh installs gammon-firewall.service, a systemd unit ordered After=docker.service, which re-runs firewall.sh at every boot. Without it, the box silently loses its container firewall at the worst possible moment. If you restart Docker by hand without rebooting, run firewall.sh again.
ports.conf: the single audit point
22/tcp # SSH
80/tcp # HTTP — Caddy (redirects to HTTPS, and serves ACME challenges)
443/tcp # HTTPS — Caddy, all projects, routed by domain
443/udp # HTTP/3This file is the only place ports are opened. firewall.sh builds both the ufw rules and the DOCKER-USER rules from it. If a port is not in this file, it is not reachable, even if some compose file publishes it.
Websites do not need an entry. Caddy already listens on 80 and 443 and routes by hostname, so a new site is a new file in deploy/sites/, not a new port. Only a raw non-HTTP service (a TCP game server, for example) needs a line here, followed by a re-run of firewall.sh. See Multiple projects on one box.
The format is one <port>/<proto> per line. Blank lines and # comments are ignored.
Hetzner Cloud Firewall: the outer layer
Create a Cloud Firewall in the Hetzner console and attach it to the server. Inbound: 22/tcp, 80/tcp, 443/tcp, 443/udp, and nothing else.
This is not redundant with firewall.sh. Hetzner's firewall runs outside the VM, in Hetzner's network. Docker's iptables rules cannot get around it, because Docker never sees it. If a compose file mistakenly publishes a port and the inner firewall is missing (for example, after a Docker restart without the systemd unit), the outer layer still blocks it. It does not depend on anything the box believes.
The trade-off: opening a raw port later means editing it in two places, ports.conf and the Cloud Firewall. DEPLOY.md calls that the right amount of friction for putting something new on the public internet.
The app itself publishes no port
In deploy/docker-compose.prod.yml, only Caddy binds host ports (80, 443, 443/udp). The game server uses expose: ["8080"] on the internal edge network. Only Caddy can reach it, so there is no route to the app that bypasses TLS.