# ───────────────────────────────────────────────────────────────────────────── # Work Package Suite — NGINX site config # # Serves the static site and reverse-proxies /api/ to the Python API # (FastAPI on 127.0.0.1:8000), which stores SOPs, Work Packages, and comments # in PostgreSQL. Same-origin, so there is no CORS. # # Install: # 1. Copy the project files to the web root (e.g. /var/www/wp-suite). # 2. Run the API as a service (see server/README.md) listening on :8000. # 3. Put this file at /etc/nginx/conf.d/wp-suite.conf # (or /etc/nginx/sites-available/ + symlink into sites-enabled/). # 4. Replace server_name and the ssl_certificate paths. # 5. sudo nginx -t && sudo systemctl reload nginx # ───────────────────────────────────────────────────────────────────────────── # Cache-Control per file type. Computed in a map rather than a nested location # because nginx's add_header is NOT inherited into a block that declares its own — # a `location ~* \.(html|css|js)$` setting only Cache-Control would silently drop the # CSP / HSTS / X-Frame-Options / nosniff headers below for exactly those files. An # empty value makes nginx omit the header, so images and fonts stay cacheable. # # Code must revalidate on every load: with no Cache-Control the browser applies # HEURISTIC freshness (~10% of the file's age), so the least recently changed file # gets the LONGEST lifetime — which is how a page ends up running against a # stylesheet or script from a previous deploy. ETag/Last-Modified keep it a 304. map $uri $wp_cache_control { default ""; ~*\.(?:html|css|js|webmanifest)$ "no-cache"; ~*/$ "no-cache"; # directory index -> index.html } # Redirect plain HTTP to HTTPS server { listen 80; server_name wp-suite.company.local; # <-- your internal hostname return 301 https://$host$request_uri; } server { listen 443 ssl; server_name wp-suite.company.local; # <-- your internal hostname # Internal certificate from your company CA ssl_certificate /etc/nginx/ssl/wp-suite.crt; # <-- cert path ssl_certificate_key /etc/nginx/ssl/wp-suite.key; # <-- key path ssl_protocols TLSv1.2 TLSv1.3; # Static site root /var/www/wp-suite; # <-- web root index index.html; # ── Security response headers (defense-in-depth) ───────────────────────── add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "no-referrer" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always; # Empty for anything that isn't code, in which case nginx omits the header. add_header Cache-Control $wp_cache_control always; location / { try_files $uri $uri/ =404; } # ── API proxy → Python (FastAPI) ───────────────────────────────────────── # All /api/ calls (SOPs, Work Packages, comments) go to the local API # service. Keep the /api/ prefix — the API routes are defined under /api. location /api/ { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; client_max_body_size 5m; # WP documents can be larger } }