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
Connections
Context
Section titled “Context”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
tlsserverprofile issues 45-day certificates since 2026-05-13; the defaultclassicprofile moves to 64 days on 2027-02-10 and to 45 days on 2028-02-16. Renewal must be fully automatic
- classic (default)
- tlsserver (opt-in)
Show as table
| Profile | Lifetime |
|---|---|
| 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 |
Options considered
Section titled “Options considered”Upstream flood absorption
Section titled “Upstream flood absorption”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.
3. Nothing upstream
Section titled “3. Nothing upstream”Pros: simplest; no vendor. Cons: a real flood takes the host down.
Reverse proxy
Section titled “Reverse proxy”A. Traefik 3.7
Section titled “A. Traefik 3.7”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.
B. Caddy 2.11
Section titled “B. Caddy 2.11”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.
C. HAProxy 3.4 LTS
Section titled “C. HAProxy 3.4 LTS”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.
D. nginx
Section titled “D. nginx”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.
Decision
Section titled “Decision”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.
Requirements for any edge
Section titled “Requirements for any edge”- 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
- One trusted client-IP header, set by the edge from the TCP connection and overwriting anything the client sent
- Flood limits before the app server: per-IP request rate, per-IP concurrent requests, request body size, and slow-client timeouts
- Traffic only to ready instances, judged by
GET /readyz - The app port is unreachable from outside; only the edge talks to it
- Health endpoints aren’t public:
/healthzand/readyzanswer the edge and Docker, not the internet
Traefik
Section titled “Traefik”-
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-sentX-Forwarded-*andX-Real-Ipand setsX-Real-Ipfrom the TCP connection. That holds only while the public entry point has noforwardedHeaders.trustedIPsand neverforwardedHeaders.insecure— either would let clients forge their IP. Apps setCLIENT_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 forgedX-Real-Ipthrough 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.healthCheckprobes/readyzevery 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
/healthzor/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: 6450/s average, burst 100 Concurrent per IP inFlightReq, same key20 Request body size buffering.maxRequestBodyBytes10 MB Slow clients entry point respondingTimeoutsread 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)
Credentials, per email10 per 15 minCredentials, per IP3 per 10 sWrites, per IP100 per minWrites, per user300 per minAny request, per IP50/s averageShow as table
Limit Layer Allowed Credentials, per email App limiter (ADR-0010) 10 per 15 min Credentials, per IP App limiter (ADR-0010) 3 per 10 s Writes, per IP App limiter (ADR-0010) 100 per min Writes, per user App limiter (ADR-0010) 300 per min Any request, per IP Edge, 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-ProtoandHostpass 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_ORIGINand passes it tocreateCsrfMiddleware({ origin })(ADR-0013) and Better Auth’sbaseURL. 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
When to add a CDN
Section titled “When to add a CDN”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.
Consequences
Section titled “Consequences”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
/readyzpasses - 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
trustedIPsorinsecureto 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.

