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.

Immutable vs purge decision flow A URL is evaluated for whether it contains an embedded content hash. Hashed URLs take the immutable path: 365-day cache, no purge ever. Non-hashed URLs take the mutable path: short TTL, deploy-time purge required. Incoming URL contains [0-9a-f]{8,}? Hash in URL? Yes No Immutable max-age=31536000 immutable 365d proxy cache no purge ever Mutable no-cache must-revalidate 10 min proxy TTL PURGE on deploy curl -X PURGE /purge/…
Hash in URL means immutable caching with no purge; no hash means short TTL and a deploy-triggered purge.

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 1m plus 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 inactive TTL is set to zero).