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.

Cache entry lifetimes across a deploy At the deploy moment the previous hashed URL stops being referenced and becomes an orphaned entry that the inactive timer reclaims. The new hashed URL creates a fresh entry valid for a year. The unhashed HTML entry keeps its key across the deploy and therefore serves stale content until a purge replaces it. After a deploy at T=0 deploy at T=0 old hash URL serving orphaned, reclaimed by inactive=60m new hash URL did not exist fresh entry, valid 365d PURGE index.html old HTML stale until purge refetched from upstream before deploy after deploy after purge
Hashed URLs rotate their identity at deploy time, so nothing needs evicting; the HTML keeps its key and stays wrong until something evicts it.

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.

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.

Location block precedence Nginx resolves an exact equals match first, then a caret-tilde prefix match that suppresses regex evaluation, then the first matching regular expression in configuration file order, and finally the longest ordinary prefix match as a fallback. Nginx location matching order 1. location = /sw.js exact match wins outright 2. location ^~ /static/ prefix match, skips regex tier 3. location ~* hashed regex first match in file order 4. location / longest prefix fallback
Precedence, not file position, decides which block serves a request — the hashed-asset regex only runs if no exact or caret-tilde block claimed the URL first.

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 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).

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.