Configure Immutable Cache-Control and Cache Busting in Nginx Behind CDN

by Fahim

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.

Close-up of server chassis and edge networking hardware in a data center
Close-up of server chassis and edge networking hardware in a data center

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=2 and your CDN strips the parameter, every user gets stuck with the cached version of /css/app.css until 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.

  1. 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.
  2. Configure Origin Shielding: Prevent stampedes against your origin when cache misses spike by setting up an origin shield with request collapsing.
  3. Short TTL on Error Statuses: In your CDN rules, cap 404 and 502 caching 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: nosniff

Now test your HTML entry point to make sure it never caches immutable directives:

curl -s -I https://app.example.com/index.html

Confirm 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.

all_in_one_marketing_tool