# Rendered by the nginx-unprivileged entrypoint: envsubst expands ${API_UPSTREAM} # (and only names that exist in the environment — nginx's own $uri/$host/etc. # are left alone) into /etc/nginx/conf.d/default.conf at container start. server { # 8080, not 80: unprivileged nginx runs as uid 101 and cannot bind <1024. listen 8080; listen [::]:8080; server_name _; root /usr/share/nginx/html; index index.html; # The bundle is already gzipped over the wire by this; Brotli would need a # module the stock image does not carry, so gzip it is. gzip on; gzip_vary on; gzip_min_length 1024; gzip_proxied any; gzip_types text/plain text/css application/javascript application/json image/svg+xml application/manifest+json; server_tokens off; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header Referrer-Policy no-referrer always; # Container-level liveness. Served from memory, so a broken bundle or a # read-only mount cannot make this lie. location = /healthz { access_log off; # default_type, not `add_header Content-Type`: add_header appends, so # the response would carry two Content-Type headers. default_type text/plain; return 200 "ok\n"; } # Same-origin API. Because the browser only ever talks to this origin, the # backend's CORS allow-list is not exercised at all in this deployment. location /api/ { proxy_pass ${API_UPSTREAM}; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Connection ""; # The FK-scraper poll endpoints are long-ish; don't cut them at 60s. proxy_read_timeout 120s; proxy_buffering off; } # Vite emits content-hashed filenames under /assets, so they are immutable. location /assets/ { access_log off; # `expires 1y` would emit its own Cache-Control alongside this one; # one explicit header is clearer than two contradicting-looking ones. add_header Cache-Control "public, max-age=31536000, immutable" always; try_files $uri =404; } # index.html is the only mutable entry point — never cache it, or a deploy # leaves clients pinned to a stale asset graph. location = /index.html { add_header Cache-Control "no-cache" always; } # SPA fallback. react-router-dom uses BrowserRouter, so a hard refresh or a # shared link on a client-only route (/calendar, /settings, ...) arrives # here as a real HTTP request for a path with no file behind it. Without # this line that is a 404 and client-side routing breaks on reload. location / { try_files $uri $uri/ /index.html; } }