If your caching headers aren’t explicitly configured, browsers and edge CDNs spend hundreds of unnecessary milliseconds revalidating static files against your origin. Here is how to configure Nginx to serve fingerprinted assets with Cache-Control: public, max-age=31536000, immutable while making sure your HTML entry points always revalidate immediately.

The Real Cost of 304 Not Modified Revalidations
When a browser loads a stylesheet or JavaScript bundle without an explicit immutable directive, it regularly fires off conditional requests with If-None-Match (ETag) or If-Modified-Since headers. Even when Nginx fires back a lightning-fast 304 Not Modified, the browser still has to wait out that round-trip network hop before parsing and executing the asset.
On a site with 35 static assets across fonts, scripts, and CSS, those round-trips create obvious rendering lag on high-latency mobile networks. When I benchmarked our asset pipeline before tuning, our edge CDN was passing roughly 22% of static asset hits straight back to the origin just to confirm ETags hadn’t changed.
The MDN documentation on Cache-Control explains that the immutable directive tells modern browsers the file’s contents will never change during its valid lifetime. That means the browser skips conditional checks entirely during standard reloads or page navigations.
Why Query String Cache Busting Fails on CDNs
Before touching your Nginx configs, make sure you aren’t relying on legacy query-string versioning:
Query strings break down behind CDNs and proxy layers for three major reasons:
- CDN Parameter Stripping: Many edge CDNs default to stripping or ignoring query parameters to boost cache hit ratios across different tracking URLs and campaigns.
- Intermediate Proxy Quirks: Corporate firewalls and mobile carrier proxies frequently drop query parameters entirely, serving stale assets indefinitely.
- Purge Traps: If you accidentally send an immutable header on
/css/app.css?v=2and your CDN strips the parameter, every user gets stuck with the cached version of/css/app.cssuntil you trigger an edge purge.
You need content-hashed filenames (asset fingerprinting) like app.8f9b2c3d.css or bundle-a74e1902.js, where the hash is computed directly from the file contents during your build step.
Configuring Your Build Tool for Content Hashing
Whether you use Vite, Webpack, or esbuild, configure your pipeline to bake cryptographic hashes directly into the output filenames. Here is a clean vite.config.js setup that handles this across scripts, stylesheets, and fonts:
import { defineConfig } from 'vite'; export default defineConfig({ build: { rollupOptions: { output: { entryFileNames: 'assets/[name].[hash:8].js', chunkFileNames: 'assets/[name].[hash:8].js', assetFileNames: 'assets/[name].[hash:8].[ext]' } } }
});This dumps every asset into an /assets/ folder with an 8-character content hash. If a CSS file doesn’t change between deployments, its hash stays identical, keeping your CDN cache warm across releases.
Nginx Configuration: Splitting Hashed Assets and HTML
Your origin server has to serve different headers depending on whether the requested file is content-hashed or mutable HTML. For a full breakdown of the infrastructure setup, check our guide on how to deploy Node.js on a VPS with Nginx and CDN.
Open your Nginx server block (usually /etc/nginx/sites-available/default or /etc/nginx/conf.d/app.conf) and split your caching rules using targeted location blocks:
server { listen 80; server_name app.example.com; root /var/www/app/dist; index index.html; # 1. Hashed static assets - 1 Year Immutable Cache location ~* ^/assets/.+.[0-9a-fA-F]{8,}.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; add_header X-Content-Type-Options "nosniff"; access_log off; try_files $uri =404; } # 2. Un-hashed static fallback assets (favicons, robots.txt, manifests) location ~* .(ico|txt|xml|webmanifest|json)$ { expires 1d; add_header Cache-Control "public, max-age=86400, must-revalidate"; access_log off; try_files $uri =404; } # 3. HTML entry points and dynamic SPA routing - Never cache immutable location / { expires -1; add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; try_files $uri $uri/ /index.html; }
}As covered in the Nginx ngx_http_headers_module documentation, expires 1y automatically generates standard Expires headers alongside your Cache-Control directives.
The add_header Inheritance Trap in Nginx
Here is a classic gotcha that burned me during a staging release: Nginx directives don’t inherit downward if you define new headers in a child block. If you define security headers (like X-Frame-Options or CORS) in the parent http or server context, and then add add_header Cache-Control inside a location block, Nginx silently drops all the parent headers for that block.
To prevent security regressions or CORS failures on static assets, manage your shared headers carefully. See our guide on CORS and Cache-Control headers for CDN static assets so web fonts and scripts don’t get blocked across subdomains.
# Correct pattern: include common headers across blocks
location ~* ^/assets/.+.[0-9a-fA-F]{8,}.(js|css|woff2)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; add_header Access-Control-Allow-Origin "*"; add_header X-Content-Type-Options "nosniff"; try_files $uri =404;
}Tuning Edge CDN Rules and Preventing 404 Cascades
When you deploy a new build, it’s tempting to wipe out old build files. But if an active user has an older index.html loaded right when the deploy finishes, their browser will try to request chunks that no longer exist on your origin.
If your CDN reaches back to the origin for that missing chunk, Nginx returns a 404 Not Found. If your CDN caches that 404 for hours, you end up breaking the UI for users across that entire edge pop.
- Keep Prior Build Assets on Origin: Don’t run
rm -rf dist/*right before copying new builds. Keep the last two or three deployment asset folders on your static storage so late-loading clients can still fetch older bundles. - Configure Origin Shielding: Prevent stampedes against your origin when cache misses spike by setting up an origin shield with request collapsing.
- Short TTL on Error Statuses: In your CDN rules, cap
404and502caching at 5 to 10 seconds. That prevents sticky outage responses. If you run into connectivity issues between your CDN and VPS, check out our steps to fix 502 Bad Gateway and SSL handshake errors.
Testing and Verifying Headers with cURL
After reloading Nginx with sudo nginx -t && sudo systemctl reload nginx, verify your headers directly against both origin and CDN edge.
Check a hashed asset first:
curl -s -I http://127.0.0.1/assets/app.8f9b2c3d.js -H "Host: app.example.com"You should see these response headers returned:
HTTP/1.1 200 OK
Server: nginx
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
X-Content-Type-Options: nosniffNow test your HTML entry point to make sure it never caches immutable directives:
curl -s -I https://app.example.com/index.htmlConfirm that Cache-Control returns no-cache, no-store, must-revalidate. This ensures browsers fetch the fresh HTML document on every visit, immediately discovering newly hashed asset filenames.
Frequently Asked Questions
What happens if I set immutable on index.html by accident?
If you serve index.html with an immutable header and a 1-year max-age, returning visitors won’t receive frontend updates until they manually wipe their browser cache or the full year elapses. Keep entry documents and service workers strictly on no-cache.
Does immutable work on older browsers like Safari 10 or IE11?
Browsers that don’t recognize immutable simply ignore the token and respect the standard max-age=31536000 directive. It won’t cause syntax errors or broken requests on legacy clients.
Can I use immutable caching with WordPress or monolithic CMS frameworks?
Yes, as long as your theme or asset pipeline writes file hashes or version fingerprints directly into filenames. You can configure Nginx to match asset paths like /wp-content/themes/your-theme/dist/ with immutable headers while leaving dynamic plugin assets on shorter TTLs.
Why does the browser still send requests when I hit reload?
A hard refresh (Shift + F5 or Cmd + Shift + R) instructs the browser to bypass its local disk cache regardless of header rules. Standard reloads or in-app page navigations will respect the immutable header and bypass the network entirely.
Next Steps
With immutable caching and hashed filenames in place, watch your CDN egress bandwidth drop and hit ratios improve. To keep your origin protected from sudden traffic spikes, take a look at how to configure an origin shield to prevent CDN origin overload.

