Reverse Proxy Configuration
Patrimoine is designed to run behind a reverse proxy that handles TLS termination. This document covers Traefik, Caddy, and the internal nginx configuration.
Architecture
Section titled “Architecture”Browser → Reverse proxy (TLS termination) → nginx (frontend container, :8080) → backend (:BACKEND_PORT)- The reverse proxy (Traefik, Caddy, or similar) terminates TLS and forwards plain HTTP to the frontend container on port 8080.
- nginx (inside the frontend container) serves the React SPA and reverse-proxies
/api/,/auth/and/healthto the backend atBACKEND_URL(http://backend:8000with compose,http://127.0.0.1:8000withpodman.sh). - The backend (uvicorn) processes API and auth requests.
Traefik
Section titled “Traefik”Middleware Chain
Section titled “Middleware Chain”Configure a middleware chain with the following middlewares:
| Middleware | Type | Purpose |
|---|---|---|
hsts |
headers |
Sets Strict-Transport-Security |
security |
headers |
Sets X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Content-Security-Policy |
content-unsafe |
headers |
Additional content security headers |
permission-strict |
headers |
Permissions-Policy restrictions |
The security middleware must set X-Forwarded-Proto: https via customRequestHeaders so the backend detects HTTPS and sets the Secure flag on cookies.
Compression Warning
Section titled “Compression Warning”Client address and keepalive
Section titled “Client address and keepalive”When Traefik terminates TLS and forwards plain HTTP to the frontend container:
- Set
FRONTEND_TRUSTED_PROXIESto the address or network Traefik connects from, as the frontend sees it (for example the subnet of the Docker network they share). nginx then takes the client address fromX-Forwarded-For, so the per-IP limits of nginx and of the backend, and the audit log, see the real client. Without it every visitor looks like Traefik and shares one budget. - nginx keeps idle connections open for 120 seconds, longer than the 90 seconds Traefik keeps idle backend connections, so Traefik never reuses a connection nginx has just closed (which would fail with a 502).
- Do not publish the frontend’s host port. Only Traefik should be able to reach nginx.
services: frontend: environment: FRONTEND_TRUSTED_PROXIES: 172.18.0.0/16 # the Docker network shared with Traefik labels: - traefik.enable=true - traefik.http.routers.patrimoine.rule=Host(`patrimoine.example.com`) - traefik.http.routers.patrimoine.entrypoints=websecure - traefik.http.routers.patrimoine.tls=true - traefik.http.services.patrimoine.loadbalancer.server.port=8080To publish the website, add a second router for the docs service (container port 8080) on your site hostname. Its Content-Security-Policy is set by the container itself (it needs 'wasm-unsafe-eval' and worker-src 'self'); do not override it with the app’s CSP.
A Caddyfile.example template is provided in the repository root.
(security_headers) { header { Strict-Transport-Security "max-age=31536000; includeSubDomains" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" Permissions-Policy "camera=(), microphone=(), geolocation=()" }}
{$DOMAIN} { reverse_proxy localhost:{$PORT} import security_headers header Content-Security-Policy "default-src 'self'; script-src 'self'; ..." encode gzip zstd}
{$SITE_DOMAIN} { reverse_proxy localhost:{$SITE_PORT:8082} import security_headers header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; ...; worker-src 'self'; ..." encode gzip zstd}The template has two site blocks: {$DOMAIN} forwards to the frontend container on {$PORT} (the frontend’s FRONTEND_HOST_PORT), and {$SITE_DOMAIN} forwards to the web container on {$SITE_PORT} (default 8082). The website block needs the relaxed CSP shown in the file (script-src 'self' 'wasm-unsafe-eval' and worker-src 'self') because the documentation search runs as a WebAssembly worker. SITE_DOMAIN must be the host of SITE_URL. Set the four variables in Caddy’s own environment, never in a Patrimoine env file.
The security headers match those set by nginx. Caddy automatically provisions Let’s Encrypt certificates.
Compression
Section titled “Compression”The encode gzip zstd directive enables compression globally — this is the shipped Caddyfile.example behavior, with no per-path split needed. Unlike Traefik’s compress middleware, Caddy’s encode does not strip Set-Cookie headers from the OIDC callback’s 302 redirect, so /auth/ does not need to be excluded.
nginx (Internal — Frontend Container)
Section titled “nginx (Internal — Frontend Container)”nginx runs inside the frontend container and listens on port 8080. It is not directly exposed when a reverse proxy is in front.
Responsibilities
Section titled “Responsibilities”- Serves the React SPA (static files from
/usr/share/nginx/html/). - Reverse-proxies
/api/,/auth/and/healthto the backend atBACKEND_URL. - Does not proxy
/metrics: Prometheus scrapes the backend directly (see Metrics). - Sets security headers (HSTS, CSP, X-Frame-Options, etc.).
- Applies rate limiting.
Compression and HTTP version
Section titled “Compression and HTTP version”The frontend container speaks HTTP/1.1 over plain HTTP and has no TLS, so it never offers HTTP/2. It compresses text types with gzip (level 6) for responses of 256 bytes or more (gzip_types in frontend/container/nginx.conf.template). It never sends brotli or zstd: a client that accepts only those receives the file uncompressed. The shipped image adds neither brotli nor HTTP/2.
This was checked with read-only requests against a running stack: a file under /assets/ requested with Accept-Encoding: gzip comes back with Content-Encoding: gzip, with br, zstd or identity it comes back without a Content-Encoding header, the status line is HTTP/1.1 200 OK, and an HTTP/2 or TLS attempt on the container port fails.
HTTP/2 and brotli at the edge can therefore only come from the reverse proxy in front of the container, and whether they are on depends on that proxy and its configuration. To check your own edge, run these two read-only requests against your public URL (replace <hash> with the name of any file listed under /assets/ in the page source):
curl -sI --http2 https://patrimoine.example.com/ | head -1curl -sI -H 'Accept-Encoding: br' https://patrimoine.example.com/assets/<hash>.js | grep -i content-encodingThe first prints HTTP/2 200 when the edge serves HTTP/2. The second prints content-encoding: br when it serves brotli, and nothing when it does not.
Rate Limiting
Section titled “Rate Limiting”| Zone | Default rate | Default burst | Scope |
|---|---|---|---|
API (/api/) |
10 req/s | 20 | Per client IP |
Auth (/auth/) |
5 req/s | 10 | Per client IP |
The rates are configurable with FRONTEND_API_RATE_LIMIT and FRONTEND_AUTH_RATE_LIMIT; the burst is twice the rate.
Security Headers
Section titled “Security Headers”nginx sets the following headers on all responses:
Strict-Transport-Security: max-age=31536000; includeSubDomainsX-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-origin, exceptno-referreron the/reset-password,/accept-inviteand/confirm-emailpages, whose emailed links carry a one-time token in the query stringPermissions-Policy: camera=(), microphone=(), geolocation=()Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
/sw.js, /manifest.json and /index.html are also sent with Cache-Control: no-cache, so browsers revalidate them instead of reusing a stale copy. For an installed app, /sw.js is what matters: it is revalidated on every load, so the app detects a new deploy and offers its update prompt. Once the service worker controls a page, navigations come from its precache and never reach nginx, so the header on /manifest.json and /index.html only affects first visits and browsers without the service worker. / and every in-app URL are served as /index.html and get the same header. Files under /assets/ have content-hashed names and are sent with Cache-Control: public, max-age=31536000, immutable, so browsers keep them for a year without revalidating, and a missing file under /assets/ answers 404 rather than the app. A caching proxy or CDN placed in front may cache them the same way, but must pass the no-cache header through rather than override it.
If the backend is exposed directly, without this nginx layer in front of it (TRUSTED_PROXY=false), nothing above applies.
In that mode only, the backend itself sets X-Content-Type-Options: nosniff, X-Frame-Options: DENY and Referrer-Policy: strict-origin-when-cross-origin on every response (SecurityHeadersMiddleware), including error responses from the rate-limit and CSRF middleware. It never sets Content-Security-Policy or Strict-Transport-Security — the backend can’t reliably know if the connection is TLS without a proxy, and a JSON API has no use for a CSP that would only interfere with /docs.
Behind the proxy (the default), the backend adds none of these headers, so nothing is duplicated.
Critical Configuration
Section titled “Critical Configuration”Uvicorn --proxy-headers
Section titled “Uvicorn --proxy-headers”Uvicorn must run with --proxy-headers so the backend trusts X-Forwarded-Proto and X-Forwarded-Host from the reverse proxy. This is the default in backend/container/Containerfile.
Without --proxy-headers, the backend cannot detect HTTPS and will not set the Secure flag on cookies, breaking OIDC authentication over TLS.
nginx X-Forwarded-Proto passthrough
Section titled “nginx X-Forwarded-Proto passthrough”The nginx configuration inside the frontend container uses $http_x_forwarded_proto instead of $scheme to forward the original protocol from the external reverse proxy:
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;This is critical because nginx listens on HTTP (port 8080) — $scheme is always http, which would overwrite the X-Forwarded-Proto: https header set by the external TLS proxy. With $http_x_forwarded_proto, the backend correctly detects HTTPS and sets the Secure flag on cookies.
nginx /auth/ Block
Section titled “nginx /auth/ Block”The shipped config sets the proxy directives once at server level; /api/, /auth/ and /health inherit them:
proxy_http_version 1.1;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $remote_addr;proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;proxy_buffering off;proxy_buffer_size 16k;proxy_busy_buffers_size 16k;proxy_request_buffering off;proxy_redirect off;location /auth/ { limit_req zone=auth burst=10 nodelay; proxy_pass http://patrimoine_backend;}If you write your own nginx in front, every one of those directives must apply to /auth/.
The following directives are critical for the OIDC callback flow:
| Directive | Why it matters |
|---|---|
proxy_http_version 1.1 |
nginx defaults to HTTP/1.0, which can break chunked transfers and Set-Cookie headers |
proxy_buffering off |
Prevents nginx from buffering 302 responses, which can drop Set-Cookie headers |
proxy_request_buffering off |
Streams request bodies without buffering |
proxy_redirect off |
Prevents nginx from rewriting Location headers in 302 responses |
proxy_buffer_size 16k and proxy_busy_buffers_size 16k |
Fit the three token cookies (access, refresh and ID tokens) that the OIDC callback sets in one 302 response; with nginx’s default one-page header buffer the callback fails with a 502. Set both: raising proxy_buffer_size alone makes nginx refuse to load the configuration |
Without these directives, Set-Cookie headers on 302 redirects are lost, causing “Missing PKCE verifier cookie” or “Missing OAuth state cookie” errors.
If proxy_buffer_size is missing or too small, the callback returns 502 Bad Gateway (upstream sent too big header in the nginx log) although the backend completed the sign-in.
Cookie Security
Section titled “Cookie Security”| Attribute | Value | Rationale |
|---|---|---|
SameSite |
Lax |
Sufficient for OIDC callback (top-level GET navigation, same-site). None causes Firefox Total Cookie Protection to partition the cookie, breaking the callback |
Secure |
true over HTTPS |
Protocol-aware: set when X-Forwarded-Proto: https is detected (requires TRUSTED_PROXY=true). Not set over plain HTTP (local dev). |
HttpOnly |
true |
Always set — prevents JavaScript access to cookies |
Path |
/ |
Explicitly set on all cookies via oidc_cookie_kwargs and auth_cookie_kwargs — ensures cookies are sent on all routes including /auth/callback |
The Secure flag is protocol-aware, not DEBUG-based. It is determined by X-Forwarded-Proto when TRUSTED_PROXY=true. This means local development over HTTP works without setting DEBUG=true.