Skip to content
BoringStack
GitHub

Firewall & TLS (single-host)

4 min read

Firewall and TLS

Firewall + TLS for a single VPS sitting behind Cloudflare. Goal: only Cloudflare can reach port 80/443; SSH stays open from operator IPs; Traefik handles TLS termination via ACME HTTP-01.

UFW

host firewall

Cloudflare

IP allowlist

ACME

auto TLS

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.

  • Inbound 22: SSH from anywhere (change SSH_PORT if 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.
  • 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).
  1. Edit compose/.env:

    Configure Public Host & ACME Email
    $ echo 'PUBLIC_UI_HOST=example.com' >> compose/.env
    $ echo 'ACME_EMAIL=ops@example.com' >> compose/.env

    The 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 at https://example.com/api/* + https://example.com/health on the same host and cert.

  2. Boot the prod stack:

    Boot Production Stack
    $ 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 network

    Traefik starts requesting ACME certs on first boot. Watch docker compose logs traefik for obtain certificate events.

  3. Run the firewall script:

    Apply UFW Firewall Rules
    $ 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 Firewall + TLS
$ 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.

Rotate Cloudflare IP allowlist
$ CONFIRM=yes ./scripts/ufw.example.sh

ok  Cloudflare IP ranges updated and UFW rules re-applied

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.