Firewall & TLS (single-host)
If you’re provisioning via the OpenTofu template, the Hetzner cloud firewall already enforces the Cloudflare-only allowlist at the provider level. This UFW path is for manually-provisioned hosts, or as defense-in-depth on top of the cloud firewall.
Outcome
Section titled “Outcome”- Inbound 22: SSH from anywhere (change
SSH_PORTif you’ve moved it). - Inbound 80, 443: only from Cloudflare IP ranges (IPv4 + IPv6).
- All other inbound: dropped.
- Outbound: unrestricted.
- Traefik on 80/443: HTTPS via Let’s Encrypt ACME HTTP-01, cert renewal automatic, HTTP redirects to HTTPS.
Prerequisites
Section titled “Prerequisites”- Your domain’s DNS is managed by Cloudflare (orange-cloud / proxied mode for the host records).
- The server has a static IPv4 address.
- You have an ACME contact email (a real address; Let’s Encrypt rejects
example.com).
-
Edit
compose/.env:Configure Public Host & ACME Emailset the public hostname and Let's Encrypt contact $ echo 'PUBLIC_UI_HOST=example.com' >> compose/.env $ echo 'ACME_EMAIL=ops@example.com' >> compose/.envThe domain must resolve to this server (via a Cloudflare proxied A/AAAA record on the apex). BoringStack uses same-origin path routing: Traefik serves the SPA at
https://example.com/*and the API athttps://example.com/api/*+https://example.com/healthon the same host and cert. -
Boot the prod stack:
Boot Production StackTraefik requests ACME certs on first boot $ STACK=prod ./scripts/compose-up.sh ok traefik started; requesting ACME cert for example.com ok api started on internal network ok ui started on internal networkTraefik starts requesting ACME certs on first boot. Watch
docker compose logs traefikforobtain certificateevents. -
Run the firewall script:
Apply UFW Firewall Rulesreset UFW, allow SSH, restrict 80/443 to Cloudflare IPs only $ CONFIRM=yes ./scripts/ufw.example.sh # Fetching Cloudflare IPv4 ranges... # Fetching Cloudflare IPv6 ranges... ok UFW reset and rules applied ok Port 22: ALLOW from anywhere ok Ports 80,443: ALLOW from Cloudflare ranges only
Verify
Section titled “Verify”$ curl -sI -H 'Host: example.com' https://localhost/health -k
$ curl -sI https://example.com/health
$ curl -sI --resolve example.com:443:<your-server-ip> https://example.com/health
ok HTTP/2 200 (local, self-signed accepted)
ok HTTP/2 200 CF-Ray: abc123... (Cloudflare proxied)
! curl: (28) Connection timed out. Direct hit blocked by UFW ✓The third check is the proof: a direct hit to the server’s IP, bypassing Cloudflare’s edge, gets dropped by UFW.
Rotation: when Cloudflare publishes new IP ranges
Section titled “Rotation: when Cloudflare publishes new IP ranges”Cloudflare publishes IPv4 and IPv6 ranges and updates them rarely (semi-annual at most). When they change, re-run the script. It resets to a clean state, re-fetches the current ranges, and re-applies. Idempotent.
$ CONFIRM=yes ./scripts/ufw.example.sh
ok Cloudflare IP ranges updated and UFW rules re-appliedWhen the firewall is NOT enough
Section titled “When the firewall is NOT enough”UFW + Cloudflare-IP-allowlist protects you from random scans hitting your origin IP. It doesn’t protect against:
- Cloudflare-routed attacks (DDoS aimed at your hostname). Use Cloudflare’s WAF + rate-limiting rules for that.
- App-layer abuse (credential stuffing, scraping). Use Traefik’s rate-limit middleware (already configured for the API router in prod) and per-account rate limits in the API.
- Compromise via SSH. Move SSH to a non-standard port, disable password auth, use a hardware key.
infra/compose/docs/security-hardening.md has a full checklist.
Related
Section titled “Related”- Deployment; the production sequence this firewall sits inside.
- Provisioning with OpenTofu; cloud-firewall path that complements UFW.
- Security pipeline; SAST and supply-chain scans alongside this network hardening.
- Backups; the other half of “ready for prod.”
- Image updates; patching cadence for the stack you just hardened.