Originally published on kuryzhev.cloud
Symptoms
Nginx cache-control tuning is one of those things that looks fine in the config file and completely falls apart in DevTools. You open the Network tab, reload the page, and every single JS, CSS, and font file shows 200 instead of 304 or (disk cache). The browser is re-downloading assets it should already have.
Zoom out to the CDN dashboard and the story gets worse. Cache hit ratio sits at 40-60% when it should be north of 90% for static assets. Bandwidth costs are creeping up month over month, and nobody on the team can point to a config change that caused it — because there wasn't one, this was baked in from day one.
Then there's the deploy problem. You ship a UI fix, and support tickets come in for two more days from users staring at the old broken layout. Or the opposite happens: users get a blank white screen right after deploy because the HTML shell got cached for a year and now references JS hashes that no longer exist on the server.
Running curl -I against an asset gives you a mixed bag: no Cache-Control header at all, or one that contradicts itself — no-cache on a fingerprinted file that should be cached forever, or a long max-age on a file with no hash in the name. Something is actively fighting your caching strategy, and it's usually Nginx itself, not the CDN.
Root cause
The most common trap: add_header in Nginx does not merge across nested blocks. If your server {} block sets Cache-Control and X-Content-Type-Options, and then a location block inside it adds even one more header, Nginx drops every header the parent block defined for that location. It's not additive — it's block-level replacement. This bites almost everyone once, usually in production, usually silently.
Second issue: teams conflate "browser cache" with "CDN cache" as if they're the same lever. Cache-Control: public, max-age=86400 controls the browser. Whether your CDN respects that same value depends on the provider — Cloudflare, Fastly, and CloudFront each have their own rules around s-maxage, and some will happily double-cache or ignore your directive entirely if Vary isn't set correctly.
Third: no cache-busting strategy. Without content-hashed filenames like app.a1b2c3.js, you can't safely set a long max-age — doing so means users are stuck on broken code for up to a year with no way out except a hard refresh. So teams under-cache everything defensively, which tanks hit ratio and inflates origin bandwidth.
Last one, and it's sneaky: testing headers with curl -I straight against the origin IP or internal hostname. That tells you what Nginx sends. It tells you nothing about what the CDN or WAF in front of it does to that header on the way out. I've debugged "Nginx isn't caching" tickets for an hour before realizing the actual problem was Cloudflare stripping Cache-Control because a page rule was set to bypass cache on that path.
Fix #1 — Set explicit, layered Cache-Control by asset type
Stop using one location / block with one Cache-Control value for everything. Fingerprinted assets, non-fingerprinted assets, and the HTML entrypoint all need different policies, and they need to live in separate location blocks (or a centralized map) so nothing gets silently overridden.
Here's the config we run in front of a typical SPA. The map directive keeps the logic in one place instead of duplicating it across a dozen location blocks — cleaner, and much harder to break during a refactor.
# /etc/nginx/conf.d/static-cache.conf
# Map content-type to cache policy so we don't repeat logic per location block
map $sent_http_content_type $cache_policy {
default "public, max-age=3600";
~*text/css "public, max-age=31536000, immutable";
~*application/javascript "public, max-age=31536000, immutable";
~*image/ "public, max-age=2592000, stale-while-revalidate=86400";
~*font/ "public, max-age=31536000, immutable";
}
server {
listen 443 ssl;
server_name example.com;
# HTML entrypoint: always revalidate, it references hashed asset filenames
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate";
add_header Vary "Accept-Encoding";
}
# Fingerprinted static assets — long-lived, immutable
location ~* \.(js|css|woff2?|svg|png|jpg|jpeg|gif|ico)$ {
add_header Cache-Control $cache_policy always;
add_header Vary "Accept-Encoding";
gzip_static on; # serve pre-compressed .gz files, skip runtime gzip CPU cost
access_log /var/log/nginx/static_access.log combined;
}
# Non-fingerprinted misc files — short max-age, no forced immutability
location = /favicon.ico {
add_header Cache-Control "public, max-age=3600";
}
}
Gotcha: that always flag on add_header matters — without it, Nginx only sends the header on 2xx/3xx responses, and error pages served through the same location silently lose caching headers, which breaks some CDN edge-case handling.
Fix #2 — Fix cache-busting via filename fingerprinting, not header tricks
The real fix for deploy staleness isn't a smarter header — it's making the filename itself unique per build. Configure your build tool (Vite, webpack, esbuild, whatever) to emit content-hashed output: app.a1b2c3.js instead of app.js. Now a long max-age is safe, because a new deploy produces a new filename, not a new version of an old one.
Only the entry HTML — the file that references those hashed filenames — needs short-lived headers. That's the file browsers must always re-check. Everything it points to can be cached for a year without risk.
If you're stuck on a legacy setup without fingerprinting, you can decouple staleness partially: serve the HTML shell with no-cache, must-revalidate while keeping long max-age on assets, then bump a query string or path prefix on deploy. It's not as clean as real content hashing, but it stops the worst of the "users stuck on old JS" problem.
Gotcha: if you're not fingerprinting and relying on a CDN, you must add a cache purge step to your CI/CD pipeline on every deploy. Forgetting this means your origin serves fresh code but the CDN edge keeps handing out yesterday's build for however long the TTL says — sometimes a full day. If your CDN supports surrogate keys or cache tags, use them instead of full purges; it's far cheaper and doesn't nuke unrelated cached content.
Fix #3 — Align Nginx, CDN, and browser cache layers
Once single-server caching works, the harder problem shows up: three layers (browser, CDN, Nginx-as-proxy) disagreeing with each other. This is where most "it works locally but not in prod" caching bugs live.
Add Vary: Accept-Encoding everywhere you serve compressed content, and add Vary: Accept if you're conditionally serving AVIF/WebP based on request headers. Without it, a CDN edge node can cache the gzip response and then hand it to a client that sent no Accept-Encoding at all — broken rendering, hard to reproduce.
If Nginx itself sits as a caching reverse proxy in front of an app server, set proxy_cache_valid and proxy_no_cache explicitly for any route touching cookies or auth. Otherwise Nginx can cache a response meant for user A and serve it to user B — that's not a performance bug, that's a data leak.
# Quick verification checklist — run after any Nginx cache config change
# 1. Confirm headers survive through CDN, not just origin
curl -sI https://example.com/assets/app.a1b2c3.js | grep -i cache-control
# Expect: Cache-Control: public, max-age=31536000, immutable
# 2. Confirm HTML entrypoint is NOT long-cached
curl -sI https://example.com/index.html | grep -i cache-control
# Expect: Cache-Control: no-cache, must-revalidate
# 3. Check actual Nginx-level cache hit ratio
tail -n 500 /var/log/nginx/access.log | awk '{print $NF}' | sort | uniq -c
# Look for ratio of HIT vs MISS/EXPIRED (upstream_cache_status)
# 4. Confirm no auth/user-specific route is cached at shared layer
curl -sI https://example.com/api/user-avatar | grep -i cache-control
# Expect: Cache-Control: private, no-store
# 5. Verify Vary header is present for compressed variants
curl -sI https://example.com/assets/app.a1b2c3.js | grep -i vary
# Expect: Vary: Accept-Encoding
Test all of this through the production CDN domain, never directly against the origin. If a WAF or edge rule strips Cache-Control, you'll only catch it by checking the full path end-to-end.
Prevention
Cache misconfig regressions come back quietly — someone touches an unrelated location block six months later and breaks header inheritance again. Build guardrails so it fails loudly instead.
Add a CI check that fails the build if new static assets ship without content hashes in the filename. This closes the door on the "someone forgot to configure the build tool" class of bugs before it reaches production.
Add a synthetic smoke test after every deploy that asserts Cache-Control on a known asset path and on /index.html. This is cheap to write and catches Nginx config drift immediately instead of three days later when a support ticket shows up.
Document the caching policy per asset class — README or an ADR, doesn't matter which — so future engineers don't "fix" the CDN layer and accidentally break the Nginx layer, or vice versa. This is a distributed system with three independent caches; treat it like one.
Finally, actually look at your hit ratio numbers, not just your config. Configured headers mean nothing if $upstream_cache_status in your access logs still shows mostly MISS. We've seen teams go from a 60% to a 95% cache hit ratio after fixing nginx cache-control tuning properly, which cut origin bandwidth bills by 3-5x — that's the number that actually gets a fix prioritized by whoever owns the cloud bill. If you're also chasing bandwidth costs on the AWS side, our notes on AWS cost and infrastructure tuning cover related territory worth checking after this fix ships.
For the header semantics themselves, the official Nginx headers module docs and the MDN Cache-Control reference are worth bookmarking — the directive list changes slowly but the interaction between immutable, stale-while-revalidate, and CDN-specific extensions is easy to get wrong twice.
Top comments (0)