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.
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.
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.
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).
Related
- Nginx cache purge for fingerprinted assets — complete configuration reference for the parent cluster
- 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 - Content hashing vs semantic versioning — why content hashes eliminate the need for purge on static assets