yum upgrades for production use, this is the repository for you.
Active subscription is required.
Technical Briefing: Far-Future Cache-Control for Versioned Assets with the NGINX Immutable Module
The Problem
Cache-busted assets such as /assets/app.a3f2c9.css carry a content hash in the URL, so a given URL’s content cannot change. Browsers still revalidate them with If-None-Match requests. Each revalidation costs:
- a TCP round-trip (or TLS resume),
- a
stat()syscall, - log I/O,
- a 304 response write.
That cost repeats for every asset and every return visitor.
The Fix
Add one directive to a location block:
immutable on;
NGINX then emits a far-future header that tells browsers to skip revalidation for the full cache lifetime. Repeat visits change from “revalidate every asset” to “read from disk, render, done.” For first visits, the article notes that NGINX Early Hints complements this by starting asset downloads before the final response is ready.
Tested environment: Rocky Linux 10, NGINX 1.30, nginx-module-immutable 0.0.7.
How the Filter Works
The module only modifies responses that pass three checks:
- Status is 200 OK. Redirects, 404s and error pages pass through untouched.
- It is a main request. Internal sub-requests are skipped.
- Content-Type matches
immutable_types, if that directive is set.
Headers emitted depend on the HTTP version.
HTTP/1.1 and newer:
Cache-Control: public,max-age=31536000,stale-while-revalidate=31536000,stale-if-error=31536000,immutable
HTTP/1.0:
Expires: Thu, 31 Dec 2037 23:55:55 GMT
What each token does:
| Token | Effect |
|---|---|
public |
CDNs and proxies may store the response. |
max-age=31536000 |
One year, the RFC-recommended maximum. |
stale-while-revalidate / stale-if-error |
Serve cached content during background revalidation or origin errors. |
immutable |
Browsers skip revalidation entirely. |
Browser caveat: Firefox and Safari honor immutable directly. Chromium-based browsers still don’t fully honor it during normal navigation. The stale-* tokens are the safety net, so don’t strip them.
Why Not expires max;
expires max; produces Cache-Control: max-age=315360000 plus Expires: Thu, 31 Dec 2037 23:55:55 GMT. Its shortcomings:
- Lifetime too long: ten years, beyond RFC 9111’s one-year guidance.
- Headers not version-aware: both headers go to every client.
- Missing tokens: no
immutableorstale-*tokens.
Practical consequences:
- Proxy caches may cap the age inconsistently.
- Lighthouse and WebPageTest may flag the value.
- Adding the missing tokens with
add_headeronly covers 2xx/3xx responses unless you addalways.
Installation
RHEL / CentOS Stream / AlmaLinux / Rocky / Fedora
sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-immutable
Add this near the top of /etc/nginx/nginx.conf, outside any block:
load_module modules/ngx_http_immutable_module.so;
Then test and reload:
sudo nginx -t
sudo systemctl reload nginx
Caveat: the RPM requires a paid repository subscription on RHEL, CentOS, AlmaLinux, Rocky Linux, Amazon Linux and Fedora.
Debian / Ubuntu
Set up the vendor’s APT repository first, then:
sudo apt-get update
sudo apt-get install nginx-module-immutable
No load_module line is needed because the package loads the module automatically.
Directives
All three directives are valid in the http, server and location contexts.
| Directive | Default | Purpose |
|---|---|---|
immutable on \| off; |
off |
Master switch. Zero overhead when off. |
immutable_cache_status on \| off; |
off |
Adds an RFC 9211 header, e.g. Cache-Status: "nginx/immutable"; hit; ttl=31536000. CDNs append their own entries to form a chain. Keep it off in production unless debugging or a CDN consumes it. |
immutable_types mime-type ...; |
unset (all types) | Works like gzip_types, but defaults to all types. Usually used to exclude downloads or feeds. |
immutable_types must list the MIME types nginx actually emits, as defined in /etc/nginx/mime.types. For example, .woff2 maps to font/woff2:
location /static/ {
immutable on;
immutable_types text/css application/javascript font/woff2;
}
Verified Scenarios
Check workflow:
sudo nginx -t
sudo systemctl reload nginx
curl -s -I http://localhost:8080/static/style.css | grep -iE 'cache-control|cache-status|expires'
| Scenario | Result |
|---|---|
Basic (immutable on;) |
Full Cache-Control header on text/css. |
Cache-Status (immutable_cache_status on;) |
Adds the Cache-Status line. |
MIME restriction (immutable_types text/css application/javascript;) |
CSS and JS get the header. readme.md (application/octet-stream) gets none. |
HTTP/1.0 (curl -s --http1.0 -I http://localhost:8080/static/style.css`) | Only theExpires` header, no Cache-Control. |
|
| HTTP/2 and HTTP/3 (`curl -sk –http2 -I https://localhost:8443/static/style.css`) | Identical behavior, because the module works on headers, not transport. |
Nested override. A child block can opt specific extensions out (reproduced as printed in the source):
location /static/ {
immutable on;
location ~* .(zip|gz|csv)$ {
immutable off;
add_header Cache-Control "no-store";
}
}
Result: style.css gets the immutable header and export.csv gets Cache-Control: no-store.
Magento 2 Pattern
location /static/ {
immutable on;
location ~ ^/static/version {
rewrite ^/static/(versiond*/)?(.*)$ /static/$2 last;
}
location ~* .(zip|gz|gzip|bz2|csv|xml)$ {
add_header Cache-Control "no-store";
immutable off;
}
}
Check the regexes before deploying. They are reproduced exactly as printed. The backslashes (\d, \.) appear to have been stripped.
How it behaves:
/static/version1234/...styles.cssrewrites internally to the unversioned file on disk and gets the far-future header.- Archives get
no-store. - Bumping the version on each deploy invalidates all caches atomically without touching any files.
Module vs. Native Directives
The hand-built equivalent is:
expires 1y;
add_header Cache-Control "public, max-age=31536000, stale-while-revalidate=31536000, stale-if-error=31536000, immutable" always;
Running curl -I against that config shows:
- an
Expiresheader sent even to HTTP/1.1+ clients, - two separate Cache-Control lines.
Further differences:
- Status coverage:
add_headerneedsalwaysto cover all status codes. The module gates on 200 explicitly in C. - Inheritance: a child
add_headerreplaces all of the parent’s headers. The module’s directive uses standard flag-merge semantics instead.
When to use which:
– Use the module for correct defaults without these pitfalls.
– Use native directives when you need custom max-age values or extra Cache-Control tokens.
Practical Payoff and Performance
- Overhead: the filter adds under a microsecond per response and short-circuits immediately when disabled.
- Real gain: fewer conditional requests and less CPU spent answering them.
- Logging suppression: combining the module with
access_log offwas verified to work. Static requests no longer appear in/var/log/nginx/access.log:
location /static/ {
immutable on;
access_log off;
}
Pitfalls
- Non-versioned URLs: if you overwrite a file like
/assets/app.cssin place, users may see stale content for up to a year. Always pair the module with hash-in-filename, version path segments, or?v=...query strings. - Module not loaded (RHEL): the package installs the
.sofile but doesn’t load it. The errorunknown directive "immutable"means theload_moduleline is missing. - Redirects: 301 and 302 responses get no header. If you need caching headers on redirect locations, add them explicitly.
- Duplicate headers: an
add_header Cache-Controlinside an immutable location produces two Cache-Control headers. Pick one source of truth per location. - Wrong MIME names: listing
application/font-woff2when nginx emitsfont/woff2silently skips your fonts. Confirm withcurl -I. - Chromium: it does not respect
immutableduring ordinary navigation, which is why thestale-*tokens stay.
Troubleshooting
Header missing:
1. Confirm the response is really a 200. Force one with curl -s -I -H 'Cache-Control: no-cache' http://localhost/…`.immutable_types
2. Ifis set, check that the Content-Type matches it.nginx -T | grep immutable` to confirm the directive is in the effective config. Another location may be catching the request first.
3. Run
Duplicate Cache-Control: remove one source, or clear an outer header with add_header Cache-Control "";. Fixing the outer config is cleaner.
Revalidation on back/forward in Chromium: this is expected. In DevTools > Network, a truly cached response shows (disk cache) or (memory cache) in the Size column rather than 304.
Bottom Line
The module is roughly 300 lines of C. It emits RFC-compliant headers, branches on HTTP version, and merges cleanly across contexts. The article calls it one of the fastest wins for:
- Magento 2,
- Webpack/Vite/Rollup frontends,
- WordPress themes with versioned
style.css, - Next.js static exports.
Recommended usage: scope it with immutable_types, debug multi-layer caches with immutable_cache_status, and silence static-asset logging with access_log off.
Read the full article: NGINX Immutable Module: Far-Future Cache-Control Headers
