Nginx Immutable Assets vs Proxy Cache Purge
The decision between serving an asset immutably and wiring up a purge mechanism is not about preference — it is determined by whether the URL encodes the content. When it does, purging is structurally unnecessary. When it does not, purging is the only reliable way to expire stale content.
The Core Distinction
A content-hashed asset has its hash baked into the filename: main-a1b2c3d4.js. When the file changes, the build tool produces main-e5f67890.js. The old URL never serves new content; the new URL never existed before. No cache — browser, proxy, or CDN — can serve stale content for the new URL because no entry for it exists yet.
A mutable URL — index.html, /api/config.json, or any path without an embedded hash — can change content while keeping the same URL. Every caching layer between the user and the origin holds a snapshot that may be outdated. Purging forces those caches to discard their snapshot and fetch a fresh copy.
Understanding the fingerprinting-in-HTTP-headers conventions that communicate immutability to clients is the prerequisite for everything below. See also the Cache-Control immutable and TTL tuning guide for how max-age and immutable interact across browser and CDN caches.
The clearest way to see the difference is to freeze a deploy at T=0 and watch what each cache entry does afterwards. The hashed asset produces a brand-new entry that nothing can contradict; the old one turns into an orphan that the inactive timer eventually reclaims. The unhashed HTML entry, by contrast, keeps its identity across the deploy — which is exactly why it is now wrong.
Comparison Table
| Dimension | Immutable hashed assets | proxy_cache_purge for mutable content |
|---|---|---|
| URL changes on content change? | Yes — hash rotates | No — same URL |
| Browser cache action needed? | None — old URL becomes dead | Must revalidate or be purged |
Nginx proxy_cache_valid TTL |
365d | 10 min (or less) |
Cache-Control header |
public, max-age=31536000, immutable |
no-cache, must-revalidate |
| Purge on deploy? | Never | Always |
| Risk of stale content? | None by construction | High if purge is skipped or delayed |
| Works without purge module? | Yes | No — requires ngx_cache_purge or Nginx Plus |
| Rollback procedure | Deploy previous hash; old URL still cached | Purge new URL, deploy old content |
| Suitable for CDN layer? | Yes — CDN respects immutable |
Yes — trigger CDN purge API alongside Nginx |
| ETag or Last-Modified needed? | No | Recommended for conditional revalidation |
Decision Matrix
Does the URL encode the file content (hash in filename)?
├── Yes → immutable strategy
│ Cache forever. Add Cache-Control: public, max-age=31536000, immutable.
│ No purge mechanism needed. No ngx_cache_purge module needed.
└── No → mutable strategy
Short proxy_cache_valid (10 min or less).
Add Cache-Control: no-cache, must-revalidate for browser.
Wire proxy_cache_purge endpoint. Trigger purge on every deploy.
The only time a “hashed” asset should be purged is during an emergency rollback where the same hash was reused for different content — which is a hash collision and indicates a broken build pipeline. Fix the pipeline; do not treat collision-driven purging as a normal operating procedure.
Full Nginx Configuration
The following nginx.conf covers both strategies in a single server block. It is complete and runnable — copy it, substitute assets.example.com and the upstream address, and reload Nginx.
worker_processes auto;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# Proxy cache zone: 10 MB key store, 2 GB max disk, evict after 60 min idle.
proxy_cache_path /var/cache/nginx/proxy_cache
levels=1:2
keys_zone=STATIC:10m
inactive=60m
max_size=2g
use_temp_path=off;
# File descriptor cache for disk-served files.
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
server {
listen 443 ssl http2;
server_name assets.example.com;
ssl_certificate /etc/ssl/certs/assets.example.com.crt;
ssl_certificate_key /etc/ssl/private/assets.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# ----------------------------------------------------------------
# IMMUTABLE STRATEGY — fingerprinted assets (hash in filename)
# Regex matches 8+ lowercase hex digits followed by the extension.
# Increase to {12,} for monorepos with thousands of output chunks.
# ----------------------------------------------------------------
location ~* \.[0-9a-f]{8,}\.(js|css|woff2?|svg|png|jpg|jpeg|webp|avif|ico)$ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_cache STATIC;
proxy_cache_key "$host$request_uri";
proxy_cache_valid 200 206 365d;
proxy_cache_lock on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
# Immutable: browser holds forever, never revalidates.
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Cache-Status $upstream_cache_status always;
# Stop upstream Cache-Control from leaking through.
proxy_hide_header Cache-Control;
proxy_hide_header Pragma;
proxy_hide_header Expires;
}
# ----------------------------------------------------------------
# MUTABLE STRATEGY — HTML entry points and unhashed resources
# Short proxy TTL + no-cache browser directive + purge on deploy.
# ----------------------------------------------------------------
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_cache STATIC;
proxy_cache_key "$host$request_uri";
proxy_cache_valid 200 10m;
proxy_cache_valid 404 1m;
proxy_cache_revalidate on;
proxy_cache_lock on;
proxy_cache_use_stale error timeout updating;
# Browser must revalidate; proxy caches for 10 min.
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Cache-Status $upstream_cache_status always;
proxy_hide_header Cache-Control;
proxy_hide_header Pragma;
proxy_hide_header Expires;
}
# ----------------------------------------------------------------
# PURGE ENDPOINT — mutable resources only, internal access only.
# Requires ngx_cache_purge module or Nginx Plus.
# ----------------------------------------------------------------
location ~ /purge(/.*) {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
proxy_cache_purge STATIC "$host$1";
}
}
# Redirect HTTP to HTTPS.
server {
listen 80;
server_name assets.example.com;
return 301 https://$host$request_uri;
}
}
Deploy Script
Add this to your CI/CD pipeline after uploading new assets and HTML to the upstream:
#!/usr/bin/env bash
set -euo pipefail
NGINX_HOST="https://assets.example.com"
PURGE_PATHS=(
"/index.html"
"/app.html"
"/sw.js"
)
echo "Purging mutable HTML entry points from Nginx proxy cache..."
for path in "${PURGE_PATHS[@]}"; do
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X PURGE "${NGINX_HOST}/purge${path}")
if [ "$HTTP_STATUS" = "200" ]; then
echo " PURGED ${path}"
else
echo " WARNING: purge returned HTTP ${HTTP_STATUS} for ${path}"
fi
done
echo "Fingerprinted assets require no purge."
Hybrid Configurations: When One Server Handles Both
In practice, most applications serve a mix: dozens of hashed JS/CSS/font files alongside a handful of mutable HTML pages, a robots.txt, a sitemap.xml, and possibly a service worker at /sw.js. The nginx.conf above handles this by order of location matching: Nginx evaluates regex location blocks before prefix blocks, so ~* \.[0-9a-f]{8,}\.… fires first for hashed filenames, and location / catches everything else.
One sharp edge: the service worker at /sw.js is a mutable URL (no hash in name) that must never be served stale. Set a dedicated short TTL:
location = /sw.js {
proxy_pass http://127.0.0.1:3000;
proxy_cache STATIC;
proxy_cache_key "$host$request_uri";
proxy_cache_valid 200 1m;
proxy_cache_revalidate on;
add_header Cache-Control "no-cache, must-revalidate" always;
add_header Service-Worker-Allowed "/" always;
add_header X-Cache-Status $upstream_cache_status always;
proxy_hide_header Cache-Control;
}
The location = exact-match block has higher priority than both the regex and the prefix, so it reliably intercepts /sw.js before the location / fallback. Include /sw.js in your purge script alongside index.html.
Getting this ordering wrong is the single most common reason a correctly written immutable block never fires. Nginx does not evaluate blocks top to bottom; it applies a fixed precedence, and only the regex tier is order-sensitive within itself.
Similarly, robots.txt and sitemap.xml should use location = /robots.txt { … } and location = /sitemap.xml { … } blocks with short TTLs and explicit purge targets. Treating every non-hashed URL as mutable — even files that change rarely — eliminates the entire class of stale-content incidents.
Verification
Run this one command after a deployment to confirm both strategies are working:
# Fingerprinted asset: expect HIT on second call, immutable header always present.
ASSET_URL="https://assets.example.com/main-a1b2c3d4.js"
curl -sI "$ASSET_URL" | grep -iE 'cache-control|x-cache-status'
# HTML entry point: expect X-Cache-Status: MISS after purge, no-cache header.
HTML_URL="https://assets.example.com/index.html"
curl -sI "$HTML_URL" | grep -iE 'cache-control|x-cache-status'
Expected output for the fingerprinted asset (after a cache hit):
cache-control: public, max-age=31536000, immutable
x-cache-status: HIT
Expected output for HTML immediately after a purge:
cache-control: no-cache, must-revalidate
x-cache-status: MISS
Monitoring Which Strategy Fires
Add a second debug header that indicates which location block matched, so you can audit log lines without inspecting the URL:
# In the immutable location block:
add_header X-Asset-Strategy "immutable" always;
# In the mutable location block:
add_header X-Asset-Strategy "mutable" always;
This makes the distinction visible in browser DevTools without requiring a log file search. Check it with:
curl -sI https://assets.example.com/main-a1b2c3d4.js | grep x-asset-strategy
# x-asset-strategy: immutable
curl -sI https://assets.example.com/index.html | grep x-asset-strategy
# x-asset-strategy: mutable
In production, you may want to suppress these debug headers from external clients. Use map to strip them on non-internal requests, or remove the add_header X-Asset-Strategy lines once the routing is confirmed correct.
What a Purge Actually Removes
A PURGE request deletes one file from proxy_cache_path and frees its slot in the shared key zone. That is the entire scope of the operation. It does not touch the browser cache of anyone who already downloaded the resource, it does not reach an upstream CDN sitting in front of Nginx, and it does not affect the file-descriptor cache that open_file_cache maintains for anything Nginx serves from disk rather than proxying. Each of those is an independent layer with its own invalidation mechanism.
That layering explains a class of incidents where a purge “does not work”. The engineer purges Nginx, the response still looks old, and the conclusion is that the module is broken. Usually one of three other layers is responsible: the browser is honouring a max-age it received earlier, a CDN in front is serving its own copy, or Nginx is answering from a cached stat() result rather than the proxy cache at all — the case covered in the open file cache and stale fingerprints deep-dive.
Confirm which layer answered before changing any configuration:
# Nginx's own view — MISS here means the purge did land.
curl -sI -H 'Cache-Control: no-cache' https://assets.example.com/index.html \
| grep -iE 'x-cache-status|age|via'
An X-Cache-Status: MISS alongside an Age header greater than zero is the signature of a CDN in front of Nginx: Nginx genuinely refetched, but the response you are looking at was assembled by an edge node. A missing X-Cache-Status entirely means the request never reached the location block you think it did — check precedence before anything else.
When to Reconsider
You need the purge endpoint even for hashed URLs in these scenarios:
- Mis-deployed build with a wrong hash. If a build system emitted a file with a content-independent hash (e.g. a timestamp-based or random hash rather than a true content hash), the same URL can point to different content across deployments. The right fix is to repair the deterministic build output configuration, but a purge may be needed as immediate remediation.
- Regulatory content removal. GDPR or legal take-down obligations may require immediate cache eviction regardless of URL structure. In this case, purge by URL and confirm with
X-Cache-Status: MISS. - Testing and staging environments. In non-production environments, content often changes without the hash rotating (manual testing, partial builds). A short
proxy_cache_valid 200 1mplus purge on rebuild simplifies the test workflow.
You can remove the purge endpoint entirely when:
- Every URL served through Nginx contains a content hash (the build tooling handles all assets, including fonts, images, and SVGs).
- HTML is served by a separate origin or CDN that is not behind this Nginx instance.
- The upstream application’s deployment itself is the cache-busting mechanism (e.g., a new deployment replaces the upstream pod, and Nginx’s
inactiveTTL is set to zero).
Frequently Asked Questions
If hashed assets never need purging, why keep the purge module installed at all?
Because the same server almost always carries at least one unhashed URL. HTML documents, /sw.js, /robots.txt, and /manifest.webmanifest are all mutable by design, and each is a URL whose cached copy can be wrong the moment a deploy finishes. The module costs nothing at runtime when no PURGE request arrives, so the practical question is not whether to install it but whether any URL on the server lacks a hash. If none do, drop it.
Can I set immutable on an unhashed URL if the content rarely changes?
No. immutable is a promise that the bytes at that URL will never change for the lifetime of the max-age, and browsers act on it by skipping revalidation entirely — including on a manual reload in several engines. Once a client has cached an immutable response for a year, no purge you issue at any server-side layer can reach it. Reserve the directive for URLs whose name is derived from their content, and give everything else a short max-age with revalidation.
Does proxy_cache_valid 200 365d mean a hashed asset occupies disk for a year?
Only if it keeps being requested. The inactive parameter on proxy_cache_path is independent of proxy_cache_valid: an entry untouched for inactive=60m is removed by the cache manager regardless of how much validity time remains. Because a rotated-out hash stops receiving traffic the moment its HTML is replaced, orphaned entries clear themselves within the hour without any deploy-time action.
How do I roll back to a previous hash if the new release is broken?
Redeploy the previous build output and republish the HTML that references the old hashed filenames, then purge only that HTML. The old hashed URLs are usually still cached and still valid, so the rollback is often faster than the original deploy. See the rollback procedure for cache keys for the ordering constraints that apply when the old artifacts have already been pruned from the origin.
Related
- Nginx cache purge for fingerprinted assets — complete configuration reference for cache zones, keys, and purge endpoints
- CDN purge strategies — how the same immutable-versus-purge split plays out across every edge provider
- Open file cache and stale fingerprints — the descriptor cache that answers with stale bytes long after a purge succeeds
- Cache-Control immutable and TTL tuning — coordinating
max-age,immutable, andstale-while-revalidateacross all caching layers - Fingerprinting in HTTP headers — how
ETag,Last-Modified, andCache-Controlcommunicate asset identity to clients