Site icon GetPageSpeed

NGINX Immutable Module: Far-Future Cache-Control Headers

NGINX Immutable Module: Perfect Cache-Control for Static Assets

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:

  1. Status code: only 200 OK responses are touched. Redirects, 404s, and error pages pass through untouched.
  2. Request type: only main requests, not internal sub-requests.
  3. MIME type (optional): if immutable_types is configured, the response Content-Type must match one of the listed types.

If all three checks pass, the filter branches on the HTTP version of the incoming request:

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:

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;
}

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:

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:

  1. expires emits both Expires and Cache-Control: max-age to every client, even HTTP/1.1+. The immutable module sends only what each client needs.
  2. add_header by default runs only on 2xx and 3xx responses. You have to remember always to cover every code. The immutable module gates on 200 OK explicitly in C, which is the correct behavior for caching headers.
  3. expires and add_header don’t merge across location contexts the way module directives do. Setting them at the http level and overriding inside a location often produces surprises, because add_header replaces the parent’s headers entirely when any add_header is present in the child. The immutable directive 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:

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:

  1. The response status is actually 200 OK, not 304, 301, or 404. A quick way to force a 200: `curl -s -I -H ‘Cache-Control: no-cache’ http://localhost/…`.
  2. If immutable_types is set, the response Content-Type matches one of the listed types.
  3. nginx -T | grep immutable shows your immutable on; directive is actually in the effective config. Sometimes a location above 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.

D

Danila Vershinin

Founder & Lead Engineer

NGINX configuration and optimizationLinux system administrationWeb performance engineering

10+ years NGINX experience • Maintainer of GetPageSpeed RPM repository • Contributor to open-source NGINX modules

Exit mobile version