diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 5ad1e6d..e8b7b85 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -495,3 +495,14 @@ things enforce that, and all three are needed: than a plain one. If you change the shell file list in `sw.js`, bump `CACHE`. + +> **NGINX note:** the `Cache-Control` value comes from a `map $uri $wp_cache_control` +> at http level, applied with a single server-level `add_header`. Do **not** move it +> into a `location` block: nginx does not inherit `add_header` into a block that +> declares its own, so a `location ~* \.(html|css|js)$` setting only `Cache-Control` +> silently drops the CSP / HSTS / X-Frame-Options / nosniff headers for exactly those +> files. After deploying, confirm both are present on one response: +> +> ```bash +> curl -sI https://wp-suite.company.local/work-package-suite.html > | grep -Ei 'cache-control|content-security-policy' +> ``` diff --git a/nginx-wp-suite.conf b/nginx-wp-suite.conf index fdfdd76..95cd139 100644 --- a/nginx-wp-suite.conf +++ b/nginx-wp-suite.conf @@ -14,6 +14,22 @@ # 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; @@ -40,6 +56,8 @@ server { 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; diff --git a/nginx/conf.d/wp-suite.conf b/nginx/conf.d/wp-suite.conf index b67673b..4e72cf0 100644 --- a/nginx/conf.d/wp-suite.conf +++ b/nginx/conf.d/wp-suite.conf @@ -2,6 +2,23 @@ # This container sits behind an external reverse proxy that handles SSL. # It listens on port 80 (plain HTTP on the internal Docker network). +# Cache-Control per file type, computed here rather than in a nested location. +# WHY A MAP: nginx's add_header is not inherited into a block that declares its own +# add_header — a `location ~* \.(html|css|js)$` that set only Cache-Control would have +# silently dropped the CSP / HSTS / X-Frame-Options / nosniff headers below for exactly +# those files. Computing the value here keeps every header in ONE scope. An empty value +# means nginx omits the header entirely, so images and fonts stay freely cacheable. +# +# Code assets must revalidate on every load: with no Cache-Control at all 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 cheap 304. +map $uri $wp_cache_control { + default ""; + ~*\.(?:html|css|js|webmanifest)$ "no-cache"; + ~*/$ "no-cache"; # directory index → index.html +} + server { listen 80; server_name wp.controls.dev; @@ -19,19 +36,11 @@ server { 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; - - # Code assets must revalidate on every load. With no Cache-Control the browser - # applies HEURISTIC freshness (roughly 10% of the file's age), so the least - # recently changed file gets the LONGEST lifetime — which is exactly how a page - # ends up running against a stylesheet or script from a previous deploy. - # ETag/Last-Modified still make the revalidation a cheap 304. - location ~* \.(html|css|js|webmanifest)$ { - add_header Cache-Control "no-cache" always; - try_files $uri =404; - } } # Proxy /api/ to the FastAPI container (service name "api" on the internal network)