Skip to content

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.


Browser → Reverse proxy (TLS termination) → nginx (frontend container, :8080) → backend (:BACKEND_PORT)
  1. The reverse proxy (Traefik, Caddy, or similar) terminates TLS and forwards plain HTTP to the frontend container on port 8080.
  2. nginx (inside the frontend container) serves the React SPA and reverse-proxies /api/, /auth/ and /health to the backend at BACKEND_URL (http://backend:8000 with compose, http://127.0.0.1:8000 with podman.sh).
  3. The backend (uvicorn) processes API and auth requests.

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.

When Traefik terminates TLS and forwards plain HTTP to the frontend container:

  • Set FRONTEND_TRUSTED_PROXIES to 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 from X-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=8080

To 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.

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 runs inside the frontend container and listens on port 8080. It is not directly exposed when a reverse proxy is in front.

  • Serves the React SPA (static files from /usr/share/nginx/html/).
  • Reverse-proxies /api/, /auth/ and /health to the backend at BACKEND_URL.
  • Does not proxy /metrics: Prometheus scrapes the backend directly (see Metrics).
  • Sets security headers (HSTS, CSP, X-Frame-Options, etc.).
  • Applies rate limiting.

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):

Terminal window
curl -sI --http2 https://patrimoine.example.com/ | head -1
curl -sI -H 'Accept-Encoding: br' https://patrimoine.example.com/assets/<hash>.js | grep -i content-encoding

The 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.

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.

nginx sets the following headers on all responses:

  • Strict-Transport-Security: max-age=31536000; includeSubDomains
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin, except no-referrer on the /reset-password, /accept-invite and /confirm-email pages, whose emailed links carry a one-time token in the query string
  • Permissions-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.


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.

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.

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.


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.