Skip to content

ADR-0017 Reverse proxy & TLS

Company standard edge — Traefik on each host terminates TLS, sets the trusted client IP, gates on /readyz, and limits floods; no CDN for now, with the volumetric-flood risk accepted.

Decision
Traefik 3.7, no CDN for nowShip & run
Status
Accepted
Date
Scope
Company-wide default for all new apps

Earlier ADRs pushed several jobs to the edge:

  • TLS termination — ours, since we self-host (ADR-0003)
  • A trustworthy client IP — the rate limiter keys anonymous traffic on it, so it must come from a header the client can’t forge (ADR-0010)
  • Flood protection — an app-level limiter still accepts every connection; floods must stop before the app server (ADR-0010)
  • Routing only to ready instances — via /readyz, so deploys don’t drop requests (ADR-0015, ADR-0016)
  • A known public origin — for Start’s CSRF check and Better Auth (ADR-0009, ADR-0013)

Deployment is Nodus’s job (ADR-0016), and Nodus may already provide some of this. So this ADR fixes what the edge must do and names a default implementation; if Nodus provides an edge, it must meet the same requirements.

Two facts shape the design:

  • No proxy on our own host can absorb a volumetric flood — the link saturates before the proxy sees a packet. Only a network with more capacity than the attacker’s can, which means a CDN or scrubbing service in front
  • Certificate lifetimes are shrinking: Let’s Encrypt’s opt-in tlsserver profile issues 45-day certificates since 2026-05-13; the default classic profile moves to 64 days on 2027-02-10 and to 45 days on 2028-02-16. Renewal must be fully automatic
Let's Encrypt certificate lifetime, in days
  • classic (default)
  • tlsserver (opt-in)
Show as table
ProfileLifetime
classic (default)90 days, until 2027-02-10
classic (default)64 days, from 2027-02-10
classic (default)45 days, from 2028-02-16
tlsserver (opt-in)45 days, since 2026-05-13

1. CDN proxy in front (Cloudflare or Bunny)

Section titled “1. CDN proxy in front (Cloudflare or Bunny)”

Pros: absorbs L3–L7 floods on a network built for it; WAF and bot rules; edge caching. Cons: it terminates TLS, so it sees plaintext — session cookies, form data, personal data. Cloudflare is a US company (transfers rest on the EU-U.S. Data Privacy Framework, with standard contractual clauses as fallback) and a single point of failure for every proxied app — it had global outages of about six hours on 2025-11-18 and 2026-02-20. Bunny is EU-based (Slovenia), which removes the jurisdiction concern but not the plaintext one. Either way, a monthly cost and a vendor in every request.

2. Hosting provider’s network DDoS protection only

Section titled “2. Hosting provider’s network DDoS protection only”

Pros: no third party sees plaintext; usually included with the hosting. Cons: L3/L4 scrubbing only, and its strength varies by provider; L7 floods reach our proxy.

Pros: simplest; no vendor. Cons: a real flood takes the host down.

Pros: MIT; built-in rateLimit (per client IP, IPv6 grouped by subnet) and inFlightReq; active health checks; automatic ACME; HTTP/3; Docker label discovery; OTLP traces and metrics (query parameters redacted in traces by default). As the first hop, it drops client-sent X-Forwarded-* and X-Real-Ip headers and sets X-Real-Ip from the TCP connection. Cons: Docker discovery needs Docker API access — root-equivalent unless proxied; configuration is spread across container labels.

Pros: the simplest configuration; automatic HTTPS; active health checks; ignores spoofed X-Forwarded-* by default. Cons: rate limiting is a third-party module (caddy-ratelimit) that needs a custom xcaddy build.

Pros: the strongest connection and request-rate controls (stick tables) and load balancing; LTS until 2031. Cons: ACME support is experimental and single-instance only; the steepest configuration.

Pros: ubiquitous; limit_req and limit_conn built in. Cons: active health checks are commercial-only (NGINX Plus), so /readyz can’t gate traffic; ACME needs the young nginx-acme module (0.4) or certbot.

Traefik 3.7 on each host is the edge: it terminates TLS, sets the client IP, routes only to ready instances, and limits floods. No CDN for now — we accept that a volumetric flood can take a host down. Whatever DDoS filtering the hosting provider includes is welcome but not relied on. Any edge — including one Nodus provides — must meet the requirements below. This is the company standard for all new apps.

  1. TLS for every public request, with automatically renewed certificates. TLS 1.2 minimum, 1.3 preferred; plain HTTP only redirects to HTTPS and serves ACME challenges
  2. One trusted client-IP header, set by the edge from the TCP connection and overwriting anything the client sent
  3. Flood limits before the app server: per-IP request rate, per-IP concurrent requests, request body size, and slow-client timeouts
  4. Traffic only to ready instances, judged by GET /readyz
  5. The app port is unreachable from outside; only the edge talks to it
  6. Health endpoints aren’t public: /healthz and /readyz answer the edge and Docker, not the internet
  • Traefik 3.7, pinned, one per host, the only container publishing ports (80/443, plus UDP 443 for HTTP/3). App, Postgres, Valkey, and the Collector publish nothing; Traefik reaches apps over the internal Docker network

  • Certificates from Let’s Encrypt via HTTP-01, renewed automatically, so shrinking lifetimes need no action. Port 80 serves only ACME challenges and redirects. DNS-01 only where a product needs wildcard certificates, with a DNS API token scoped to that zone’s DNS edits

  • Client IP is X-Real-Ip. As the first hop, Traefik deletes client-sent X-Forwarded-* and X-Real-Ip and sets X-Real-Ip from the TCP connection. That holds only while the public entry point has no forwardedHeaders.trustedIPs and never forwardedHeaders.insecure — either would let clients forge their IP. Apps set CLIENT_IP_HEADER=x-real-ip, used by the rate limiter (ADR-0010) and Better Auth (advanced.ipAddress.ipAddressHeaders). A guard test against staging sends a forged X-Real-Ip through Traefik and checks it is ignored (ADR-0014)

  • Discovery: Docker labels through a read-only Docker socket proxy that allows only container listing — never the raw socket, which is root on the host. If Nodus writes routing config, Traefik’s file provider replaces labels

  • Health-checked load balancing: each service’s loadBalancer.healthCheck probes /readyz every 5 s with a 2 s timeout; failing instances leave the pool. Nodus’s rolling deploys are expected to start the new instance, wait for it to pass, then stop the old one

  • Public routers don’t serve /healthz or /readyz — Traefik’s health checks go straight to the container

  • Flood limits (starting values, tune per product):

    Control Middleware / setting Starting value
    Requests per IP rateLimit, client IP from the connection, ipv6Subnet: 64 50/s average, burst 100
    Concurrent per IP inFlightReq, same key 20
    Request body size buffering.maxRequestBodyBytes 10 MB
    Slow clients entry point respondingTimeouts read 30 s, write 60 s, idle 90 s

    These sit well above the app’s per-user and per-endpoint limits (ADR-0010) — they stop floods, not normal heavy use. Counters stay in Traefik’s memory: the edge never gets access to Valkey, which holds session tokens

    Requests allowed per minute, per key (log scale)
    • App limiter (ADR-0010)
    • Edge, Traefik (this ADR)
    Show as table
    LimitLayerAllowed
    Credentials, per emailApp limiter (ADR-0010)10 per 15 min
    Credentials, per IPApp limiter (ADR-0010)3 per 10 s
    Writes, per IPApp limiter (ADR-0010)100 per min
    Writes, per userApp limiter (ADR-0010)300 per min
    Any request, per IPEdge, Traefik (this ADR)50/s average
  • Host hardening below Traefik, since nothing filters in front of it: kernel SYN cookies on (net.ipv4.tcp_syncookies=1), connection-tracking table sized for the host, and an nftables per-source-IP limit on new connections to 80/443, so a single IP can’t exhaust Traefik’s connections. The host firewall opens only 80/443 (and SSH from the management network)

  • Headers: X-Forwarded-Proto and Host pass through; HSTS (max-age=63072000; includeSubDomains) is set at the edge. Other security headers (CSP and friends) belong to the app

  • Public origin is configuration, not inference: every app sets APP_ORIGIN and passes it to createCsrfMiddleware({ origin }) (ADR-0013) and Better Auth’s baseURL. Nothing relies on the proxy-reconstructed request URL

  • Observability: Traefik exports traces and metrics over OTLP to the endpoint the platform provides (ADR-0015); access logs are JSON on stdout. OTLP log export stays off while Traefik marks it experimental. Alert on sustained 429 rates and connection counts — the first sign of a flood

Revisit this decision — and add Bunny or Cloudflare in front of Traefik — when any of these happens:

  • a sustained attack takes a host down, or Traefik’s 429 and connection alerts fire repeatedly
  • a product gets an availability commitment (SLA) or handles payments
  • a public launch or campaign expects traffic well beyond one host

The requirements above already describe a CDN setup: the client-IP header becomes the CDN’s, the firewall opens 80/443 only to the CDN’s ranges, and origin TLS is verified by the CDN.

The deciding factors: no third party seeing plaintext traffic and no vendor in every request, an edge with flood controls, health-checked routing, and automatic TLS built in, and a client IP no client can forge. We accept that volumetric floods are out of our reach until a CDN is added.

Easier:

  • TLS ends on our own hosts; no third party sees session cookies or personal data
  • Rate limiting, Better Auth, and logs all see the same real client IP, from the TCP connection
  • Deploys don’t drop requests: traffic moves only to instances whose /readyz passes
  • Certificates renew themselves, however short lifetimes get
  • No CDN bill, contract, or data-processing agreement

Harder:

  • A volumetric or large distributed flood takes the host down, and every app on it. Traefik and nftables limits stop single-source and moderate L7 abuse, not a botnet that stays under per-IP limits or a link-saturating flood
  • Traefik is directly on the internet: patch it promptly, and keep its TLS settings current
  • One misconfiguration — adding trustedIPs or insecure to the public entry point — makes every client IP forgeable; the guard test must stay
  • No WAF or bot protection; abuse handling is ours, in the app and at Traefik
  • Traefik, its socket proxy, nftables rules, and any DNS API token are ours to run and rotate
  • Starting limits are judgment, not measurement; tune them per product from Traefik and app metrics

Given up: flood absorption on a network bigger than ours, plus a CDN’s WAF, bot rules, and edge caching. Accepted for now — keeping plaintext traffic on our own hosts and avoiding a vendor in every request outweighs flood risk at our current exposure; the triggers above say when that changes.