Every time a browser revalidates /assets/app.a3f2c9.css with an If-None-Match request, your server burns CPU cycles just to reply “nothing changed.” Multiply that by every CSS, JS, font, and image file on a page, then by every return visitor, and the waste compounds fast. The cruel twist: the URL already contains a content hash, so the file was never going to change in the first place. The browser just didn’t know that, and without the nginx immutable module telling it so, it will keep asking.
The nginx immutable module fixes this with a single directive. Flip immutable on; inside a location block and NGINX starts emitting a far-future Cache-Control header that ends with the immutable attribute, telling browsers to skip revalidation entirely for the full cache lifetime. Pair it with a cache-busted URL scheme (hash-in-filename, version segments, content-addressed paths) and you shift repeat visits from “revalidate every asset” to “read from disk, render, done.”
This guide covers what the module does, why its defaults beat expires max;, how to install and configure it on RHEL-family and Debian/Ubuntu systems, how to restrict it to specific MIME types, and how to verify everything with curl. Every snippet in the article was runtime-tested on Rocky Linux 10 with NGINX 1.30 and nginx-module-immutable 0.0.7.
What the nginx immutable module actually does
Under the hood, the nginx immutable module registers a header filter that runs on every response. When immutable on; is set for the matching location, the filter checks three things:
- Status code: only
200 OKresponses are touched. Redirects, 404s, and error pages pass through untouched. - Request type: only main requests, not internal sub-requests.
- MIME type (optional): if
immutable_typesis configured, the responseContent-Typemust match one of the listed types.
If all three checks pass, the filter branches on the HTTP version of the incoming request:
- HTTP/1.1 and newer receive a
Cache-Controlheader:Cache-Control: public,max-age=31536000,stale-while-revalidate=31536000,stale-if-error=31536000,immutable - HTTP/1.0 clients fall back to a far-future
Expiresheader instead:Expires: Thu, 31 Dec 2037 23:55:55 GMT
This split matters. HTTP/1.0 predates Cache-Control, so sending one to a pre-1.1 client just wastes bytes. Mainstream NGINX modules like ngx_http_headers_module aren’t this careful: expires max; sends both headers to everyone, forever.
The header is short but every token earns its keep:
| Token | What it does |
|---|---|
public |
Allows shared caches (CDNs, reverse proxies) to store the response |
max-age=31536000 |
One year in seconds. This is the RFC-recommended maximum for “never expires” responses |
stale-while-revalidate=31536000 |
Serve stale content while revalidating in the background. Covers edge cases where immutable isn’t honored |
stale-if-error=31536000 |
Serve stale content if the origin returns an error. Keeps assets available during outages |
immutable |
Tells modern browsers to skip revalidation entirely for the cache lifetime |
The three stale-* directives are a safety net. Chromium-based browsers still don’t fully honor the immutable attribute during normal navigation, so the module adds extended staleness windows to cover the gap. Firefox and Safari honor immutable directly.
Why 1 year instead of expires max;
The stock expires max; directive in NGINX produces:
Cache-Control: max-age=315360000
Expires: Thu, 31 Dec 2037 23:55:55 GMT
That’s ten years, a full order of magnitude over what RFC 9111 recommends for far-future caching. The spec explicitly advises against sending Expires more than one year out:
To mark a response as “never expires,” an origin server sends an Expires date approximately one year from the time the response is sent. HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future.
Most browsers tolerate the ten-year value anyway, but three things break subtly:
- Proxy caches may cap the cache age at their own threshold, producing inconsistent behavior across the layer stack.
- Lighthouse and WebPageTest sometimes flag non-compliant max-age values as audit issues.
expires max;doesn’t addimmutable,stale-while-revalidate, orstale-if-error. You’d need a companionadd_headerblock to bolt them on, and that block only runs on2xxand3xxresponses unless you addalways.
The immutable module condenses all of that into one directive and stays RFC-compliant by design.
Installation
RHEL, CentOS Stream, AlmaLinux, Rocky Linux, Fedora
Add the GetPageSpeed extras repository, then install the module package:
sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-immutable
The module ships as a dynamic module. Load it by adding the following line near the top of /etc/nginx/nginx.conf, outside any block:
load_module modules/ngx_http_immutable_module.so;
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
The RPM is free on Fedora. On RHEL, CentOS, AlmaLinux, Rocky Linux, and Amazon Linux, it requires a GetPageSpeed subscription.
Module page for RPM-based systems: nginx-extras.getpagespeed.com/modules/immutable.
Debian and Ubuntu
First, set up the GetPageSpeed APT repository, then install:
sudo apt-get update
sudo apt-get install nginx-module-immutable
On Debian and Ubuntu, the package handles module loading automatically. No load_module directive is needed.
Module page for APT-based systems: apt-nginx-extras.getpagespeed.com/modules/immutable.
Directives
The nginx immutable module exposes three directives. All of them are valid in the http, server, and location contexts, so you can set a site-wide default and override it per location.
immutable
Syntax: immutable on | off;
Default: immutable off;
Context: http, server, location
The master switch. When off (the default), the module is inactive and no headers are added, so the module stays compiled in with zero overhead. When on, the filter runs for every matching response and emits the far-future Cache-Control or Expires header described above.
immutable_cache_status
Syntax: immutable_cache_status on | off;
Default: immutable_cache_status off;
Context: http, server, location
When enabled, the module adds an RFC 9211 Cache-Status header to every response it touches:
Cache-Status: "nginx/immutable"; hit; ttl=31536000
This is pure observability. It tells downstream caches and humans alike that NGINX’s immutable layer processed the response. In multi-layer architectures (NGINX, CDN, browser), each cache appends its own entry, producing a chain like:
Cache-Status: "nginx/immutable"; hit; ttl=31536000, "cloudflare"; fwd=uri-miss; stored
Leave it off in production until you need it for debugging or a CDN that consumes the header.
immutable_types
Syntax: immutable_types mime-type ...;
Default: (unset; applies to all MIME types)
Context: http, server, location
Restricts the immutable headers to responses with specific MIME types. When unset, the module applies to every matching 200 response regardless of content type. When set, only responses whose Content-Type is in the list receive the headers.
This behaves like gzip_types, but with one key difference: the default is “all types” rather than “text/html only.” The reasoning is that cache-busting URLs rarely apply to HTML pages, so narrowing the filter is usually done to exclude certain types (downloads, feeds) rather than to opt specific types in.
Example: apply immutable caching only to CSS, JavaScript, and WOFF2 fonts. Match the MIME types nginx actually emits (check /etc/nginx/mime.types), not the ones you think it should. Modern nginx maps .woff2 to font/woff2, so that’s what goes in the list.
location /static/ {
immutable on;
immutable_types text/css application/javascript font/woff2;
}
Runtime verification
Every configuration snippet below was tested on a fresh Rocky Linux 10 install with the following layout:
/var/www/static/style.css # text/css, "html { color: red; }"
/var/www/static/app.js # application/javascript, "console.log(42);"
/var/www/static/readme.md # application/octet-stream (no nginx mime)
After each change, the workflow is always:
sudo nginx -t
sudo systemctl reload nginx
curl -s -I http://localhost:8080/static/style.css | grep -iE 'cache-control|cache-status|expires'
Scenario 1: basic immutable on
server {
listen 8080;
root /var/www;
location /static/ {
immutable on;
}
}
Expected response:
HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: public,max-age=31536000,stale-while-revalidate=31536000,stale-if-error=31536000,immutable
Scenario 2: adding Cache-Status for observability
location /static/ {
immutable on;
immutable_cache_status on;
}
Expected response:
HTTP/1.1 200 OK
Cache-Control: public,max-age=31536000,stale-while-revalidate=31536000,stale-if-error=31536000,immutable
Cache-Status: "nginx/immutable"; hit; ttl=31536000
Scenario 3: restricting by MIME type
location /static/ {
immutable on;
immutable_types text/css application/javascript;
}
style.css(text/css) gets Cache-Controlapp.js(application/javascript) gets Cache-Controlreadme.md(application/octet-stream) gets no Cache-Control header
Scenario 4: HTTP/1.0 Expires fallback
Using curl --http1.0 to force a pre-1.1 request:
curl -s --http1.0 -I http://localhost:8080/static/style.css
Response:
HTTP/1.1 200 OK
Expires: Thu, 31 Dec 2037 23:55:55 GMT
No Cache-Control header: the module correctly detects the HTTP/1.0 request and emits Expires instead.
Scenario 5: nested override with immutable off
A common pattern: enable immutable caching for a whole directory, then opt specific extensions out.
location /static/ {
immutable on;
location ~* \.(zip|gz|csv)$ {
immutable off;
add_header Cache-Control "no-store";
}
}
Result: /static/style.css gets the full immutable Cache-Control, while /static/export.csv gets Cache-Control: no-store.
Scenario 6: HTTP/2 and HTTP/3
The module operates on headers, not transport, so it works identically across HTTP/1.1, HTTP/2, and HTTP/3. Tested on HTTP/2 with a self-signed certificate:
curl -sk --http2 -I https://localhost:8443/static/style.css
HTTP/2 200
cache-control: public,max-age=31536000,stale-while-revalidate=31536000,stale-if-error=31536000,immutable
cache-status: "nginx/immutable"; hit; ttl=31536000
A real-world cache-busting pattern: Magento 2
Magento 2 uses versioned static asset URLs of the form /static/version1234567890/frontend/.... The sample NGINX config shipped with Magento rewrites those back to unversioned files on disk. The nginx immutable module drops in cleanly:
location /static/ {
immutable on;
# Strip the version prefix so files on disk stay unversioned
location ~ ^/static/version {
rewrite ^/static/(version\d*/)?(.*)$ /static/$2 last;
}
# Archives shouldn't be cached long-term
location ~* \.(zip|gz|gzip|bz2|csv|xml)$ {
add_header Cache-Control "no-store";
immutable off;
}
}
What happens in practice:
/static/version1234/frontend/Magento_Theme/css/styles.cssrewrites internally to/static/frontend/Magento_Theme/css/styles.css, which exists on disk, and the response carries the far-futureCache-Controlheader./static/frontend/export.zipmatches the nested location, so it getsCache-Control: no-storeinstead.
Because the version segment is part of the public URL but not the filesystem path, Magento can bump the version on every deploy and invalidate all caches atomically without touching any files.
Where the nginx immutable module beats native NGINX directives
NGINX already has an expires directive and an add_header directive. You can cobble together equivalent behavior:
location /static/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, stale-while-revalidate=31536000, stale-if-error=31536000, immutable" always;
}
That works, but has three sharp edges:
expiresemits bothExpiresandCache-Control: max-ageto every client, even HTTP/1.1+. The immutable module sends only what each client needs.add_headerby default runs only on2xxand3xxresponses. You have to rememberalwaysto cover every code. The immutable module gates on200 OKexplicitly in C, which is the correct behavior for caching headers.expiresandadd_headerdon’t merge across location contexts the way module directives do. Setting them at thehttplevel and overriding inside alocationoften produces surprises, becauseadd_headerreplaces the parent’s headers entirely when anyadd_headeris present in the child. Theimmutabledirective merges with the standard flag-merge semantics.
Running the snippet above with curl -I confirms the first two problems in one shot: you get Expires: [date] plus two separate Cache-Control header lines, one from expires (max-age=31536000) and one from add_header. Browsers will usually concatenate them, but the duplicate is ugly and easy to get wrong. The module emits one clean header.
Use the module when you want the correct thing to happen without thinking about the footguns. Use native directives when you need custom max-age values or additional Cache-Control tokens outside what the module emits.
Performance considerations
The filter is cheap. It runs once per response, adds or checks at most three headers, and short-circuits immediately when disabled. On a cold response path, overhead is under a microsecond, well below NGINX’s own request accounting.
More importantly, the module reduces server load by cutting revalidation requests. Each conditional request (If-None-Match/If-Modified-Since) still costs:
- A full TCP round-trip (or TLS resume).
- An
stat()syscall on the file. - Log I/O (unless
access_log offis set in the location). - A 304 response write.
Eliminate even half of those on a busy Magento or WordPress site and you free up meaningful CPU for real work. Combine with access_log off inside the /static/ location for maximum impact:
location /static/ {
immutable on;
access_log off;
}
Verified at runtime: with this config, a curl -I /static/style.css still returns the full immutable Cache-Control header, and the corresponding request does not appear in /var/log/nginx/access.log. The two directives stack cleanly.
Common pitfalls
Applying immutable on; to non-versioned URLs. The module tells browsers “this URL will never change.” If the URL can actually change (you overwrite /assets/app.css in place on every deploy), users will see stale CSS for up to a year. Always pair immutable on; with a cache-busting scheme: hash-in-filename, version path segments, or query strings with ?v=....
Forgetting to load the dynamic module. On RHEL-family systems, the package installs the .so file but does not auto-load it. If nginx -t complains with unknown directive "immutable", check that /etc/nginx/nginx.conf starts with:
load_module modules/ngx_http_immutable_module.so;
On Debian/Ubuntu this is handled by the package scripts automatically.
Expecting the header on redirects. The module filter returns early for any non-200 response, including 301 and 302. If you redirect a versioned URL to a canonical path, add caching headers explicitly to the redirect location or accept that the redirect itself won’t be cached aggressively.
Layering add_header Cache-Control inside an immutable location. NGINX’s add_header directive appends to the response, so you can end up with two Cache-Control headers: one from the module, one from your block. Browsers will usually merge them, but the result is surprising. Pick one source of truth per location.
Listing MIME types that nginx doesn’t emit. immutable_types is matched against the actual Content-Type nginx sends, which comes from /etc/nginx/mime.types. If you list application/font-woff2 but nginx maps .woff2 to font/woff2, the filter skips your fonts silently. Always confirm with curl -I before trusting the config.
Assuming Chromium fully respects immutable. It doesn’t, at least not during ordinary navigation. The stale-while-revalidate and stale-if-error attributes are the safety net for browsers that revalidate anyway. Don’t strip those tokens out “to clean up the header”; they matter.
Troubleshooting
nginx -t fails with “unknown directive ‘immutable'”. The module is installed but not loaded. Add load_module modules/ngx_http_immutable_module.so; to /etc/nginx/nginx.conf.
Header doesn’t appear in the response. Check three things:
- The response status is actually
200 OK, not304,301, or404. A quick way to force a200: `curl -s -I -H ‘Cache-Control: no-cache’ http://localhost/…`. - If
immutable_typesis set, the responseContent-Typematches one of the listed types. nginx -T | grep immutableshows yourimmutable on;directive is actually in the effective config. Sometimes alocationabove it catches the request first.
Two Cache-Control headers in the response. You have both immutable on; and an explicit add_header Cache-Control ... in the same (or nested) location. Remove one. To keep the module’s header and drop a add_header from an outer block, use add_header Cache-Control ""; to clear it, though changing the outer config is usually cleaner.
Browser still sends revalidation requests on back/forward navigation. This is Chromium’s known behavior. The stale-while-revalidate attribute ensures the response is still served from cache while the revalidation happens in the background. Check DevTools > Network: a truly cached response shows (disk cache) or (memory cache) in the Size column rather than 304.
Conclusion
The nginx immutable module is a 300-line C file that does exactly one job: emit the correct far-future caching headers for versioned static assets, and nothing else. It picks RFC-compliant defaults where expires max; picks non-compliant ones, it branches on HTTP version where native directives don’t, and it merges cleanly across http/server/location contexts where add_header famously doesn’t.
If your site serves cache-busted assets from predictable paths (Magento 2, any Webpack/Vite/Rollup-powered frontend, WordPress themes with versioned style.css, Next.js static exports), flipping immutable on; inside the relevant location is one of the fastest performance wins available. Combine it with immutable_types to scope the module to real assets, immutable_cache_status to diagnose multi-layer cache chains, and access_log off to stop logging static noise.
For the broader context on browser caching (Cache-Control token semantics, cache-busting strategies, CDN integration) see the companion article NGINX browser caching for static files. Package details, version history, and install notes for every supported distribution live on the module pages: RPM and APT.

