Skip to content

fix: set Cache-Control headers and enable gzip in the nginx config - #220

Open
matthewberry wants to merge 2 commits into
developfrom
mjberry/nginx-cache-headers
Open

matthewberry wants to merge 2 commits into
developfrom
mjberry/nginx-cache-headers

Conversation

@matthewberry

Copy link
Copy Markdown
Contributor

Problem

nginx/default.conf sets no Cache-Control on the app's own files, so browsers fall back to heuristic freshness based on Last-Modified. Every file in the image carries the build time as its mtime, so on a deployment that has been running for a while a browser can treat index.html as fresh for a day or more without revalidating. After a new image rolls out, that stale index.html requests hashed bundles that no longer exist, and try_files $uri /index.html answers with index.html and a 200, producing module MIME-type / "failed to fetch dynamically imported module" errors until a hard refresh. The nginx:1.23.3 base image also ships with gzip commented out, so the main bundle goes over the wire uncompressed.

This is the fleet rollout of moleculemaker/CLEANDB-frontend#60 (reviewed there). The generator source for new projects is fixed in moleculemaker/frontend-dev-tools#11 and moleculemaker/frontend-template#7.

Change

One file, nginx/default.conf.

Path class Cache-Control Notes
Hashed build output: root-level [name].<16+ lowercase hex>.<ext>, which is how the legacy webpack browser builder names every hashed file: bundles, lazy chunks, and the fonts and images referenced from CSS all land at the root public, max-age=31536000, immutable A missing hashed file is a real 404 instead of falling through to index.html
/assets/config/envvars.json no-store Written by entrypoint.sh at container start
Everything else (index.html, SPA routes, favicon.ico, everything under assets/) no-cache ETag/Last-Modified still sent; revalidated on every use

Plus gzip on with gzip_vary, gzip_min_length 1024, and types for CSS, JS, JSON, SVG and EOT (HTML is always included; woff/woff2 and images are already compressed).

Immutable rule: ^/[^/]+\.[0-9a-f]{16,}\.[A-Za-z0-9]+$. It is case-sensitive and anchored to the root so that files copied verbatim from src/assets (never hashed) are excluded; none of this repo's assets match it. The regex is quoted because nginx otherwise reads the {n} brace as a block opener.

The hash is 16 hex characters, not the 20 the [contenthash:20] template suggests, because build-angular sets webpack's hashFunction to xxhash64 (a 64-bit digest). Verified against a real Angular 15 build (main.9637a10c19cda006.js); the rule accepts 16 or more so a longer digest in a later version still matches. This is a different rule from the one in CLEANDB-frontend#60, which uses the application builder's -XXXXXXXX format; that rule would match nothing here.

This repo has no proxy location, so none is carried over.

  • try_files $uri /index.html; becomes try_files $uri /index.html =404; to match the other frontends. No behavior change: /index.html always exists so the =404 arm is never reached.

Verification

The new file was built into a FROM nginx:1.23.3 image with this repo's Dockerfile COPY lines and entrypoint.sh, started with the env vars from envvars.tpl.json, and probed with curl against a production ng build of clean-frontend (Angular 15, webpack browser builder, outputHashing: all). That build has the same builder and output layout as this repo (@angular-devkit/build-angular:browser, outputHashing: "all"); this repo itself was not built locally. nginx -t passed. Fake files were added for the edge probes (ketcher… at the root to prove the immutable rule wins over any proxy rule; a hashed file with an unlisted extension).

Path Status Cache-Control
/ 200 no-cache OK
/index.html 200 no-cache OK
/some/spa/route 200 no-cache OK
/main.9637a10c19cda006.js 200 public, max-age=31536000, immutable OK
/styles.8f037a6bbdf88809.css 200 public, max-age=31536000, immutable OK
/color.dae87a04d07ca92b.png 200 public, max-age=31536000, immutable OK
/ketcher.0123456789abcdef0123.js 200 public, max-age=31536000, immutable OK
/main.deadbeefdeadbeefdead.js 404 (none) OK
/nope.0123456789abcdef0123.png 404 (none) OK
/assets/config/envvars.json 200 no-store OK
/assets/price_maxsep.csv 200 no-cache OK
/favicon.ico 200 no-cache OK

SPA route body is index.html: yes
gzip main: 781829 -> 249467 bytes (1 Content-Encoding header)
envvars.json substituted by entrypoint: yes

Not verified: requests to the proxy upstream (the block is unchanged), and behavior behind the ingress. Also not closed: the rolling-update window where a new index.html is served by a new pod and a chunk is requested from an old one; that is now a hard 404 (recoverable with a reload) rather than a heuristically cacheable 200 with HTML, and the fix belongs in the chart.

The nginx config sent no Cache-Control on the app's own files, so
browsers relied on heuristic freshness from Last-Modified. Every file in
the image has the build time as mtime, so a browser could hold
index.html for a day or more, then request hashed bundles a new deploy
had removed and receive index.html with a 200 for them. gzip was also
off in the base image.

- Hashed build output (webpack browser builder: root [name].<16+ hex>.ext):
  public, max-age=1y, immutable; a missing file is a real 404.
- /assets/config/envvars.json (written by entrypoint.sh): no-store.
- Everything else, including index.html and src/assets copies: no-cache.
- gzip on for HTML, CSS, JS, JSON, SVG and EOT.

Fleet rollout of moleculemaker/CLEANDB-frontend#60; this repo's proxy
block is carried over unchanged.
entrypoint.sh ran nginx as a child of bash, and bash as PID 1 does not
forward SIGTERM. A stopping container therefore ran out its whole
termination grace period (measured: `docker stop -t 30` took 30s; with
exec it takes 0s). In Kubernetes that meant the old pod kept serving for
the full grace period after the new pod was Ready, which is the window
where a freshly served index.html asks an old pod for chunks it no longer
has. With exec, nginx is PID 1, receives SIGTERM directly and exits at
once, so the overlap shrinks to endpoint propagation and every rollout
finishes about 30 seconds sooner.
@matthewberry

Copy link
Copy Markdown
Contributor Author

Added one more commit: exec nginx in entrypoint.sh. bash as PID 1 never forwarded SIGTERM to nginx, so a stopping container ran out its full termination grace period. Measured on the runtime image: docker stop -t 30 took 30 s before, 0 s after. In Kubernetes the old pod was therefore still serving for the whole grace period after the new pod became Ready, which is the rolling-update window the "not closed" note above describes. With exec it shrinks to endpoint propagation, and each rollout finishes about 30 s sooner. The probe harness still passes with the new entrypoint (config substitution, all headers, gzip).

@matthewberry

Copy link
Copy Markdown
Contributor Author

Checked (independently of the PR body): the head nginx/default.conf was run in nginx:1.23.3 with a synthetic file tree and probed with curl. nginx -t passes. Headers land in the intended classes: /, /index.html and an SPA route are no-cache; hashed files are public, max-age=31536000, immutable and a missing hashed name is a hard 404; /assets/config/envvars.json is no-store; assets/* is no-cache; JS and CSS over 1 KiB come back Content-Encoding: gzip. The hash regex is the webpack form (.[0-9a-f]{16,}), which is what this repo's browser builder emits; probed with both 16- and 20-hex names. angular.json (@angular-devkit/build-angular:browser, outputHashing: all) and the Dockerfile COPY path (dist/digital-molecule-maker) match the layout the rules assume. The base image's STOPSIGNAL is SIGQUIT, so with exec nginx the container now gets nginx's graceful shutdown rather than sitting out the grace period.

Findings: none.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant