# ========================================================================= # Maestro Platform — production reverse proxy. # # One origin serves both the app and the API, which is why the frontend needs # no baked-in backend URL and why there is no CORS to configure. # # DOMAIN comes from .env.prod. Set it to a bare hostname for automatic TLS # (`maestro.example.com`), or to `http://localhost` for a local smoke test, # which tells Caddy to skip certificate provisioning. # ========================================================================= {$DOMAIN} { encode zstd gzip # Security headers on every response — API, app, and Umami alike. Caddy # adds none of these on its own. CSP is proxy-level and pragmatic: # 'unsafe-inline' stays because Next's hydration scripts, the JSON-LD # block in the root layout, and React style attributes all break without # nonces (nonce-based CSP = Next middleware work, tracked separately). # The only external origin is the Sentry browser SDK; self-hosted Sentry # operators edit the connect-src line. HSTS is ignored by browsers over # plain HTTP, so the localhost smoke test is unaffected. header { Strict-Transport-Security "max-age=31536000; includeSubDomains" Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://*.sentry.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" Permissions-Policy "camera=(), microphone=(), geolocation=()" } # Separate block: a deletion op defers every op in its block until the # response is written, and deferred ops are skipped on Caddy-generated # error responses (e.g. 502) — merged, the security headers above would # vanish from error pages. header { -Server -X-Powered-By } # Access logs to stdout as JSON — collected by Docker, rotated by the # json-file caps in docker-compose.prod.yml. Uptime-monitor hits on /health* # are logged too; filtering them at the proxy is not worth the config. log { output stdout format json } # /health (liveness) and /health/ready (dependency readiness) are the # backend's probes, exposed for uptime monitors — the /health* glob covers # both. Everything the API owns lives under /api, including the two WebSocket # routes — reverse_proxy upgrades those with no extra configuration, and # applies no read timeout to a hijacked connection, so a 30-minute task # stream stays open. @api path /api/* /health* handle @api { reverse_proxy backend:8000 } # Umami analytics (optional, COMPOSE_PROFILES=analytics). Only the tracker # script and the collect endpoint are public; the dashboard is deliberately # not exposed — reach it through an SSH tunnel to the loopback port # published in compose. The /a prefix exists because Umami's native # /api/send would collide with the backend's /api/* matcher above; # strip_prefix restores the path Umami expects. On deployments without the # analytics profile this upstream does not exist and a hand-crafted request # gets a 502 — the frontend never emits one, so nobody sees it. @umami path /a/script.js /a/api/send handle @umami { uri strip_prefix /a reverse_proxy umami:3000 } # Everything else, /docs included: that path is a marketing page here, not # FastAPI's Swagger UI (which is disabled when ENVIRONMENT=production). handle { reverse_proxy frontend:3000 } }