Skip to content
BoringStack
Star

Firewall & TLS

3 min read

Secure your single VPS with UFW firewall rules that allow only Cloudflare IPs on ports 80/443, plus SSH access. Traefik on the VPS requests TLS certificates from Let’s Encrypt via ACME HTTP-01 and redirects plain HTTP to HTTPS.

If you provisioned via OpenTofu, the Hetzner cloud firewall already enforces the Cloudflare-only allowlist at the provider level. This UFW setup is for manually-provisioned hosts, or as defense-in-depth on top of the cloud firewall.

  • Inbound port 22 (SSH): open from anywhere (adjust SSH_PORT if you’ve moved it).
  • Inbound ports 80, 443: only from Cloudflare IP ranges (IPv4 + IPv6).
  • All other inbound: dropped.
  • Outbound: unrestricted.
  • Traefik: HTTPS via Let’s Encrypt, auto-renewing, HTTP redirects to HTTPS.
  • Your domain’s DNS is on Cloudflare in proxied mode (orange cloud), with records for the apex and app.
  • The server has a static IPv4 address.
  • An ACME contact email (real address; Let’s Encrypt rejects example.com).
  • An app record for the app host, proxied, alongside the apex record.
Terminal window
echo 'PUBLIC_SITE_HOST=example.com' >> compose/.env
echo 'PUBLIC_UI_HOST=app.example.com' >> compose/.env
echo 'SITE_CONTACT_EMAIL=hello@example.com' >> compose/.env
echo 'SITE_IMAGE_TAG=v0.1.0' >> compose/.env
echo 'ACME_EMAIL=ops@example.com' >> compose/.env

The apex (example.com) serves the public site. The app lives on app.example.com, and Traefik routes /api/* and /health on that host to the API and everything else on it to the UI. Both hosts need a Cloudflare proxied A/AAAA record pointing at this server. Traefik issues one Let’s Encrypt certificate per host.

To keep the single-domain layout instead, set WITH_SITE=0 and PUBLIC_UI_HOST=example.com, and add only the apex record.

Terminal window
STACK=prod ./scripts/compose-up.sh

Traefik will request ACME certificates on first boot. Watch the logs:

Terminal window
docker compose logs traefik | grep -i acme

Wait for obtain certificate to complete before applying UFW rules.

Terminal window
CONFIRM=yes ./scripts/ufw.example.sh

The script fetches current Cloudflare IP ranges and applies UFW rules:

UFW reset and rules applied
Port 22: ALLOW from anywhere
Ports 80,443: ALLOW from Cloudflare ranges only

Three checks against the app host, plus one for the apex:

Terminal window
# Local (self-signed OK)
curl -sI -H 'Host: app.example.com' https://localhost/health -k
# Via Cloudflare (the normal path)
curl -sI https://app.example.com/health
# Direct to server IP (should timeout - UFW is working)
curl -sI --resolve app.example.com:443:<your-vps-ip> https://app.example.com/health --max-time 5
# Public site on the apex
curl -sI https://example.com/

Expected results: the first two and the apex return 200, the third times out. The timeout proves the firewall is blocking direct access.

Cloudflare publishes new IP ranges here and here semi-annually. Re-run the script when they change:

Terminal window
CONFIRM=yes ./scripts/ufw.example.sh

Idempotent. Safe to run anytime.

  • DDoS aimed at your domain: Use Cloudflare’s WAF and rate-limiting rules.
  • App-layer attacks: Traefik rate-limit middleware (already configured for the API) and per-account rate limits in the API.
  • SSH compromise: Use a non-standard SSH port, disable password auth, use a hardware key.

See Security for a full checklist.