Set up ufw and the Hetzner Cloud Firewall on Hetzner Debian 13 (Trixie)
Two firewalls, one allowlist, zero lockouts. A step-by-step walkthrough of a default-deny firewall on a fresh Hetzner server running Debian 13 (Trixie): ufw at the host layer, the Hetzner Cloud Firewall at the edge, rate-limited SSH, and the Docker-versus-ufw gotcha that catches most small teams out exactly once.
Why two firewalls, not one
A host-level firewall and a cloud firewall solve different problems. ufw enforces policy on the VM itself — every process that opens a socket is subject to its rules. The Hetzner Cloud Firewall enforces policy before traffic reaches the VM at all, saving CPU cycles and, more importantly, giving you a layer that still works if ufw is ever misconfigured or disabled.
Configure both with the same allowlist. Defence in depth is not redundancy — it is the acknowledgement that configuration drifts, and a second independent gate catches drift before it becomes an incident. The extra five minutes per server is cheap compared to explaining a breach to customers.
Prerequisites
- SSH access as a sudo-capable admin user. If you are still logging in as root, follow the harden-SSH guide linked below first.
- The port your SSH server actually listens on — 22 on a default image, something else if you have already hardened SSH. Picking the wrong port here locks you out.
- Access to the Hetzner web console or
doctl/hcloudCLI for the edge firewall rules.
Before running any of the commands below, open a second SSH session in a separate terminal and keep it open until the end of the guide. If anything goes wrong, that second session is how you recover without reaching for the provider's rescue console.
Part 1. Install ufw and set defaults
ufw is usually already installed on Debian 13 (Trixie) images, but it is inactive. Set the default policies before adding any rules — the default-deny stance is the whole point, and you want it in place before you enable the firewall.
sudo apt-get update sudo apt-get install -y ufw sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw default deny routeddefault deny routed is the one most guides skip. It blocks traffic that the host is asked to forward between networks — relevant the moment Docker or WireGuard is installed, because both turn the host into a router. Setting it now costs nothing and prevents a whole category of later surprise.
Part 2. Allow SSH before enabling ufw
This is the step that locks people out. Adding the SSH allow rule before enabling the firewall is non-negotiable. If you have already moved SSH off port 22, substitute your actual port.
sudo ufw allow 2222/tcp comment 'ssh' sudo ufw limit 2222/tcp comment 'ssh rate limit'The limit rule tells iptables to drop connections from any source that tries more than six times in 30 seconds. It does not replace fail2ban — it runs earlier, at the connection level, and absorbs the bulk of scanner noise before SSH even sees it. The two tools complement each other.
Part 3. Allow the public application ports
Most teams expose HTTP and HTTPS. If your reverse proxy listens on anything else, substitute the ports accordingly.
sudo ufw allow 80,443/tcp comment 'http https'If your architecture puts a load balancer in front of the VPS, scope the rule to the load balancer's address range instead of opening to the world. sudo ufw allow from 10.0.0.0/16 to any port 80,443 proto tcp is the pattern — it forces traffic to come in through the LB and blocks direct-to-IP scans entirely.
Part 4. Enable ufw
sudo ufw --force enable sudo ufw status verboseVerify status in your original session, then confirm you can still open a new SSH connection from a fresh terminal. Only close the spare session once the new one succeeds.
Part 5. Cloud firewall at the Hetzner edge
Configure Hetzner Cloud Firewall rules in the Hetzner Console or via the hcloud CLI. They apply outside the VM and should be used in addition to ufw on the host.
Mirror the host allowlist at the cloud firewall:
- TCP 2222 from your office IPs or VPN range — not from anywhere. SSH should never be reachable from the global internet if you can help it.
- TCP 80 and 443 from
0.0.0.0/0and::/0once the app is ready to go public. - Allow ICMP echo from anywhere if you want the box to be pingable for monitoring; drop it otherwise.
- Block everything else inbound. Default-deny at the edge matches default-deny at the host.
Outbound rules at the edge firewall are worth a second look if your provider supports them. Locking outbound traffic to the IP ranges your app actually talks to (payment APIs, your database, package mirrors) is a meaningful exfiltration mitigation — but it is also the kind of rule that breaks in strange ways when an upstream service adds an IP, so keep a documented path to loosen it in a hurry.
Part 6. The Docker gotcha every team meets once
If Docker is installed on this host, parts of the iptables rule set are managed by the Docker daemon, and Docker's rules run before ufw's. The practical effect: a container published with -p 80:80 is reachable from the internet even if ufw says deny. Most small teams discover this by accidentally exposing a development container to the world.
Two ways to handle it, ordered by preference:
- Publish containers to localhost, not the public interface.
-p 127.0.0.1:8080:8080keeps the container off the public stack entirely, and a reverse proxy (Caddy is simplest) terminates TLS on the ufw-allowed ports. This is what the Docker deploy guide recommends; it sidesteps the firewall question completely. - Patch ufw to filter Docker's forwarded traffic. Append explicit rules to
/etc/ufw/after.rulesunder theufw-user-forwardchain that drop forwarded traffic to container networks by default. The communityufw-dockerscript implements this correctly; read it before applying it.
If you have not installed Docker yet, follow the install-Docker guide linked below for a setup that plays nicely with this one from the start.
Operational checklist
- Review allowlists quarterly. Scoped SSH ranges drift as the team grows or VPN IPs change. A 15-minute calendar block per quarter is the cheapest audit you can do.
- Keep ufw rules in sync with the edge firewall. If you ever find yourself adding a rule to one and not the other, write a runbook step that forces both. Configuration drift between layers is how the second gate quietly stops working.
- Log denies while tuning.
sudo ufw logging mediumcaptures blocked traffic in/var/log/ufw.log; useful for diagnosing "why is this service broken" in the days after a new rule ships. Turn it back down to low once things are stable so it does not flood the journal. - Automate drift detection. Run
sudo ufw status numberedfrom a scheduled job and alert on diffs. Small teams rarely do this manually; a platform should.
Troubleshooting
I enabled ufw and lost my SSH session
sudo ufw disable, re-add the SSH allow rule, then ufw --force enable. Always open a second SSH session before firewall changes next time.The app is up locally but unreachable from the internet
0.0.0.0 or the public interface (not 127.0.0.1), ufw has an allow rule for the port, and the Hetzner Cloud Firewall has a matching rule. Traffic has to pass all three.ufw status shows rules but traffic still gets through unexpectedly
sudo iptables -L DOCKER-USER -n -v — if you see ACCEPT rules pointing at container networks, Docker is bypassing ufw. See Part 6.ufw rate-limit keeps locking me out
ufw allow from YOUR_IP to any port 2222 proto tcp, or tune the multiplexing so one session handles many commands.FAQ
Why ufw instead of raw nftables?
Is the edge firewall really necessary if ufw is in place?
What about IPv6?
IPV6=yes is set in /etc/default/ufw (the Debian default). The edge firewall needs the same rules set for the ::/0 IPv6 range; both Hetzner and other major providers support this from the same UI.