server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields). client_max_body_size 25m; # Resolve backend-api through Docker's embedded DNS on each request instead of # once at startup. `docker compose up` recreates backend-api with a new IP; # a static upstream keeps the old one and every API call 502s until nginx restarts. # proxy_pass with a variable and no URI forwards the original request URI unchanged. resolver 127.0.0.11 valid=10s ipv6=off; set $backend_api http://backend-api:8000; # Security headers on every response. add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header Referrer-Policy strict-origin-when-cross-origin always; # Same-origin API proxy. The SPA is built with an empty VITE_API_BASE so # fetch('/jobs/fetch') stays on this host. OpenAPI (/docs, /redoc, # /openapi.json) is intentionally NOT proxied. # # Paths that are BOTH React routes (/jobs, /inbox, …) and API prefixes must # require a sub-path: otherwise a cold open / refresh of /jobs is stolen by # the proxy and returns a FastAPI 404 instead of index.html. # SPA page roots that also prefix API calls — sub-path required. location ~ ^/(jobs|inbox|pipeline|tasks|assessments|offers|managers|analytics|notifications)/ { proxy_pass $backend_api; 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 Authorization $http_authorization; proxy_connect_timeout 10s; proxy_send_timeout 120s; proxy_read_timeout 120s; proxy_request_buffering off; } # API-only prefixes (no SPA page at the bare path). location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms|department)(/|$) { proxy_pass $backend_api; 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 Authorization $http_authorization; proxy_connect_timeout 10s; proxy_send_timeout 120s; proxy_read_timeout 120s; proxy_request_buffering off; } # One SPA at `/`. Without this, /auth/confirm-email (a router path, not a # file) 404s when a confirmation email link is opened cold. location / { try_files $uri $uri/ /index.html; } # Hashed filenames, so they can be cached hard. A miss after a rebuild is a # stale tab (old import() hash) — 404 + immutable would pin that miss for a # year, so JS falls through to a one-shot reload of index.html. location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header Referrer-Policy strict-origin-when-cross-origin always; location ~ \.js$ { expires 1y; add_header Cache-Control "public, immutable"; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header Referrer-Policy strict-origin-when-cross-origin always; try_files $uri @stale_js; } } location @stale_js { default_type application/javascript; add_header Cache-Control "no-store" always; add_header X-Content-Type-Options nosniff always; return 200 "try{if(!sessionStorage.getItem('tf-chunk-reload')){sessionStorage.setItem('tf-chunk-reload','1');location.reload();}else{sessionStorage.removeItem('tf-chunk-reload');}}catch(e){location.reload();}"; } # index.html must never be cached, or a redeploy keeps serving the old asset hashes. location = /index.html { etag off; if_modified_since off; add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always; add_header Pragma "no-cache" always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header Referrer-Policy strict-origin-when-cross-origin always; } gzip on; gzip_min_length 1024; gzip_types text/css text/javascript application/javascript application/json image/svg+xml; }