Nginx Cache Purge for Fingerprinted Assets

Configure Nginx as a caching reverse proxy for fingerprinted static assets: define cache zones, set cache keys, serve hashed files with immutable headers, and purge only what needs purging.

When to Use Nginx Proxy Caching vs Relying on a CDN

Nginx proxy caching makes sense in three situations: your stack has no upstream CDN and Nginx is the sole edge layer, you run a private network where a cloud CDN is prohibited, or you need sub-millisecond cache hits from RAM-backed storage that commercial CDN PoPs cannot guarantee for your geography. Against the nearest alternative — simply passing all requests to the upstream application and caching at the CDN layer — Nginx proxy caching trades operational simplicity for lower latency and full control over cache key design, X-Accel acceleration, and purge timing.

Fingerprinted assets change the calculus in one critical way: content-hashed files never need to be purged once deployed. The filename encodes the content, so a stale cache hit is structurally impossible for hashed paths. Purging is only needed for mutable resources — HTML entry points, API responses, or any URL without a hash in its name. Understanding that split between immutable and mutable URLs is the prerequisite for every decision in this guide.

Cross-reference the cache-key architecture reference before choosing a key scheme, and see the Cloudflare cache rules guide and CloudFront invalidation guide for cloud CDN equivalents.

Prerequisites

  • Nginx 1.18 or later (1.24+ recommended for full proxy_cache_revalidate support).
  • ngx_cache_purge module compiled in, or Nginx Plus R14+ for the commercial proxy_cache_purge directive. The open-source module is available via the libnginx-mod-http-cache-purge package on Debian/Ubuntu, or can be compiled with --add-module=/path/to/ngx_cache_purge.
  • Upstream application server reachable from Nginx (e.g. Node.js on 127.0.0.1:3000).
  • /var/cache/nginx/ directory writable by the nginx worker user.

To confirm the purge module is loaded:

nginx -V 2>&1 | grep -o 'ngx_cache_purge\|--add-module.*purge'

Configuration Reference Table

Directive Context Default Effect
proxy_cache_path http Defines disk location, key zone, size, and inactive TTL for the cache
proxy_cache_key http, server, location $scheme$proxy_host$request_uri String Nginx hashes to form the cache lookup key
proxy_cache http, server, location off Activates a named cache zone
proxy_cache_valid http, server, location Sets TTL per HTTP status code
proxy_cache_use_stale http, server, location off Serves stale content when upstream errors or is updating
proxy_cache_lock http, server, location off Prevents cache stampede by serialising concurrent misses
proxy_cache_revalidate http, server, location off Uses conditional requests to revalidate expired cached items
proxy_cache_bypass http, server, location Variables that, when non-empty/non-zero, skip the cache
proxy_no_cache http, server, location Variables that, when non-empty/non-zero, skip storing the response
open_file_cache http, server, location off Caches file descriptors and metadata for disk-served static files
add_header X-Cache-Status http, server, location Exposes $upstream_cache_status for debugging

Step-by-Step Implementation

Step 1: Define the Cache Zone

Add proxy_cache_path in the http block. This must come before any server block.

http {
    # Shared key-value store: 10 MB holds ~80,000 keys.
    # Inactive content evicted after 60 minutes.
    # Cap total disk usage at 2 GB.
    proxy_cache_path /var/cache/nginx/proxy_cache
        levels=1:2
        keys_zone=ASSETS:10m
        inactive=60m
        max_size=2g
        use_temp_path=off;

    # (rest of http block follows)
}

levels=1:2 creates a two-level directory tree under /var/cache/nginx/proxy_cache, preventing filesystem slowdowns from thousands of files in a single directory. use_temp_path=off writes cache files directly to the cache directory instead of a temp dir, which avoids cross-device rename overhead on systems where /tmp is a separate filesystem.

Step 2: Fingerprinted Asset Location — Immutable Headers, No Purge Needed

Hashed assets match a regex that detects 8-hex-character (or longer) sequences embedded in the filename. Cache them forever: the filename changes when the content changes, so a stale hit is impossible.

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;

    # Fingerprinted assets: 8-hex hash before the extension.
    # Examples: main-a1b2c3d4.js  styles-ff001122.css  logo-deadbeef.svg
    # Note: use 12-16 hex chars for monorepos with thousands of chunks.
    location ~* \.[0-9a-f]{8,}\.(js|css|woff2?|svg|png|jpg|webp|avif|ico)$ {
        proxy_pass         http://127.0.0.1:3000;
        proxy_cache        ASSETS;
        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;

        # Immutable: browser and any intermediate cache holds forever.
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        add_header X-Cache-Status $upstream_cache_status always;

        # Prevent Nginx from overriding Cache-Control from upstream.
        proxy_hide_header Cache-Control;
        proxy_hide_header Pragma;
    }

    # HTML entry points and mutable resources.
    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_cache        ASSETS;
        proxy_cache_key    "$host$request_uri";
        proxy_cache_valid  200 10m;
        proxy_cache_revalidate on;
        proxy_cache_use_stale error timeout updating;

        add_header Cache-Control "no-cache, must-revalidate" always;
        add_header X-Cache-Status $upstream_cache_status always;

        proxy_hide_header Cache-Control;
    }
}

The proxy_hide_header Cache-Control directive stops upstream application headers from leaking through and overriding the response headers Nginx sets. Without it, a Cache-Control: no-cache from the origin would reach the browser and defeat the immutable directive you added.

Step 3: Purge Configuration (Open-Source ngx_cache_purge Module)

Fingerprinted assets never need purging, but HTML entry points and non-hashed API responses do. Restrict the purge endpoint to trusted internal IPs.

server {
    listen 443 ssl http2;
    server_name assets.example.com;

    # (ssl + other directives as above)

    # Purge endpoint — accessible only from localhost and the deploy server.
    location ~ /purge(/.*) {
        allow 127.0.0.1;
        allow 10.0.0.0/8;       # internal deploy network
        deny  all;

        proxy_cache_purge ASSETS "$host$1";
    }
}

Send a purge request after deploying a new index.html:

curl -X PURGE https://assets.example.com/purge/index.html

The ngx_cache_purge module matches against the same key scheme defined in proxy_cache_key. The key for index.html under assets.example.com is assets.example.com/index.html, which matches $host$1 where $1 captures /index.html from the URI.

Step 4: Nginx Plus Commercial Purge

If you run Nginx Plus, replace the open-source module with the native proxy_cache_purge directive:

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;

    location ~* \.[0-9a-f]{8,}\.(js|css|woff2?|svg|png|jpg|webp|avif|ico)$ {
        proxy_pass         http://127.0.0.1:3000;
        proxy_cache        ASSETS;
        proxy_cache_key    "$host$request_uri";
        proxy_cache_valid  200 365d;
        proxy_cache_lock   on;
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503;

        add_header Cache-Control "public, max-age=31536000, immutable" always;
        add_header X-Cache-Status $upstream_cache_status always;

        proxy_hide_header Cache-Control;
    }

    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_cache        ASSETS;
        proxy_cache_key    "$host$request_uri";
        proxy_cache_valid  200 10m;
        proxy_cache_revalidate on;

        add_header Cache-Control "no-cache, must-revalidate" always;
        add_header X-Cache-Status $upstream_cache_status always;

        proxy_hide_header Cache-Control;
    }

    # Nginx Plus native purge — no separate module needed.
    location ~ /purge(/.*) {
        allow 127.0.0.1;
        allow 10.0.0.0/8;
        deny  all;

        proxy_cache_purge ASSETS "$host$1";
    }
}

The directive syntax is identical between the open-source module and Nginx Plus; the difference is that Nginx Plus builds it in while the open-source version requires the third-party module.

Step 5: Cache Key Design

The default proxy_cache_key value is $scheme$proxy_host$request_uri. Override it:

proxy_cache_key "$host$request_uri";

This drops the scheme from the key, which means HTTPS and HTTP responses share the same cached entry — acceptable when you redirect all HTTP to HTTPS before Nginx caches anything. If you serve both protocols from the same Nginx instance without a redirect, keep $scheme$host$request_uri.

For multi-tenant deployments where a single Nginx serves multiple virtual hosts from one upstream, keep $host in the key. Omitting it would cause one tenant’s cached asset to be served to another if the request URIs coincide.

Query strings are included in $request_uri. If your fingerprinting strategy uses query parameters (/main.js?v=a1b2c3d4) rather than filename embedding, the full query string is automatically part of the cache key — see the cache key with query parameters guide for tradeoffs. Filename-embedded hashes are strongly preferred because CDN caches and many HTTP intermediaries ignore or strip query strings.

Step 6: open_file_cache for Disk-Served Static Files

When Nginx serves files directly from disk (via root/alias rather than proxy_pass), proxy_cache does not apply. Use open_file_cache to cache file descriptors and metadata in RAM, reducing syscall overhead on high-request-rate workloads.

http {
    open_file_cache          max=10000 inactive=30s;
    open_file_cache_valid    60s;
    open_file_cache_min_uses 2;
    open_file_cache_errors   on;
}

open_file_cache is not a content cache — it does not store response bodies. Its benefit is eliminating repeated open(2), fstat(2), and getdents(2) calls for files that are accessed frequently. On NFS or network-mounted storage, it also reduces metadata round-trips.

For disk-served fingerprinted assets, combine open_file_cache with long-lived Cache-Control headers:

location ~* \.[0-9a-f]{8,}\.(js|css|woff2?|svg|png|jpg|webp|avif|ico)$ {
    root /var/www/assets;
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    add_header X-Cache-Status "STATIC" always;

    open_file_cache_errors on;
}
Nginx cache flow A client request enters Nginx. The URL is matched against a location block. Fingerprinted hashed URLs get immutable Cache-Control and are stored in the proxy cache zone for 365 days. Mutable HTML URLs pass through the cache zone with a 10-minute TTL and can be purged via the /purge endpoint. Client browser Nginx location match + cache lookup Hashed URL immutable, 365d no purge needed Mutable URL 10 min TTL PURGE on deploy Upstream app server PURGE /purge/index.html
Nginx routes fingerprinted URLs to a 365-day immutable cache zone; mutable HTML gets a 10-minute TTL and a dedicated purge endpoint hit on every deploy.

Verification

After reloading Nginx (nginx -s reload) and making a first request, confirm cache behavior with curl -I:

# First request — should be a MISS (populates cache).
curl -sI https://assets.example.com/main-a1b2c3d4.js | grep -i 'cache-control\|x-cache-status'
# Cache-Control: public, max-age=31536000, immutable
# X-Cache-Status: MISS

# Second request — should be a HIT (served from Nginx proxy cache).
curl -sI https://assets.example.com/main-a1b2c3d4.js | grep -i 'cache-control\|x-cache-status'
# Cache-Control: public, max-age=31536000, immutable
# X-Cache-Status: HIT

$upstream_cache_status returns one of: HIT, MISS, BYPASS, EXPIRED, STALE, UPDATING, REVALIDATED. If you see BYPASS when you expect a HIT, check whether proxy_cache_bypass conditions are being triggered by a cookie or header in the request.

Inspect cache files directly:

# Count cached objects.
find /var/cache/nginx/proxy_cache -type f | wc -l

# Print cache metadata for a specific key (requires Nginx debug or manual inspection).
ls -lh /var/cache/nginx/proxy_cache/

Verify the purge endpoint for a mutable resource:

# Issue a purge.
curl -X PURGE https://assets.example.com/purge/index.html
# Expected: 200 Successful purge

# Confirm the next GET is a MISS (repopulates from upstream).
curl -sI https://assets.example.com/index.html | grep x-cache-status
# X-Cache-Status: MISS

Edge Cases and Known Issues

Stale-while-update race for mutable HTML. When proxy_cache_use_stale updating is active and the upstream is slow, multiple concurrent requests see UPDATING and are served stale content. This is intentional and prevents a thundering-herd of simultaneous upstream hits. Fingerprinted assets are immune because they never expire in the cache.

Key mismatch after proxy_cache_key change. If you change the proxy_cache_key expression on a live server, existing cache entries are unreachable by the new key — they become orphans consuming disk space until the inactive timeout. Flush the cache directory and restart Nginx after changing the key:

rm -rf /var/cache/nginx/proxy_cache/*
nginx -s reload

ngx_cache_purge module version compatibility. The community ngx_cache_purge module is not maintained at the same cadence as Nginx. On Nginx 1.22+, verify the module compiles cleanly. If you hit build errors, pin to a tested pair or switch to Nginx Plus for the native directive.

proxy_hide_header order of precedence. add_header directives in a location block replace — not append — headers set in an outer server block. If your server block sets add_header X-Served-By nginx;, it will disappear in any location that also uses add_header. Use always to ensure headers are sent on error responses too.

open_file_cache and frequently updated files. open_file_cache_valid 60s means Nginx re-checks file metadata every 60 seconds. If you update a static file in-place without changing its name — which you should never do with fingerprinted assets — Nginx may serve the old version for up to 60 seconds. Fingerprinted deployment eliminates this problem by construction.

Gzip and Brotli interaction. If you have gzip_static on; or brotli_static on; in the same location block as proxy_pass, Nginx will attempt to serve pre-compressed .gz/.br files from disk rather than proxying. These directives are mutually exclusive with proxy_pass in practice. Compress assets at build time and serve them via the upstream, or use a separate static-file location block without proxy_pass.

CI/CD Integration: Automating Purge on Deploy

Fingerprinted assets need no action in CI — they self-invalidate by URL rotation. The pipeline only needs to purge the HTML entry points that reference the new hashed filenames. Wire the purge step to fire immediately after the upstream application receives new files.

GitHub Actions Example

# .github/workflows/deploy.yml
name: Deploy and Purge
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Build assets
        run: npm run build

      - name: Upload build artifacts to origin
        run: |
          rsync -avz --delete dist/ deploy@origin.example.com:/var/www/app/
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}

      - name: Purge mutable HTML from Nginx cache
        run: |
          PURGE_PATHS=("/" "/index.html" "/sw.js" "/app-shell.html")
          for path in "${PURGE_PATHS[@]}"; do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
              -X PURGE "https://assets.example.com/purge${path}")
            echo "Purge ${path}: HTTP ${STATUS}"
            [ "$STATUS" = "200" ] || exit 1
          done
        env:
          DEPLOY_HOST: ${{ secrets.NGINX_HOST }}

The rsync step uploads all build output — hashed JS, CSS, fonts, and the new index.html — in one atomic transfer. The purge step fires only after rsync exits cleanly, guaranteeing the origin holds the new index.html before Nginx evicts the old cached copy. Reversing that order would create a window where clients receive a fresh index.html referencing hashed filenames that have not yet landed on the origin.

Manifest-Driven Targeted Purge

For large applications with many HTML pages, parse the build manifest to identify only the entry points that actually changed rather than purging a static list:

#!/usr/bin/env bash
set -euo pipefail

MANIFEST="dist/.vite/manifest.json"
NGINX_HOST="https://assets.example.com"

# Extract unhashed entry point paths (keys without [hash] in filename).
ENTRY_PATHS=$(jq -r 'to_entries[]
  | select(.value.isEntry == true)
  | .value.file
  | select(test("\\.[0-9a-f]{8,}\\.") | not)' "$MANIFEST")

if [ -z "$ENTRY_PATHS" ]; then
  echo "No mutable entry points found in manifest — skipping purge."
  exit 0
fi

while IFS= read -r path; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    -X PURGE "${NGINX_HOST}/purge/${path}")
  echo "Purge /${path}: HTTP ${STATUS}"
done <<< "$ENTRY_PATHS"

This pattern works with Vite’s .vite/manifest.json. For Webpack, parse stats.json with jq and filter for isInitial: true chunks whose names do not match the hash regex.

Deployment Atomicity and the Two-Phase Upload

A common failure mode: new index.html lands on the origin before the new hashed JS files do. Clients requesting the HTML immediately after it appears get a page that references main-e5f67890.js, but Nginx’s upstream still has main-a1b2c3d4.js. The browser fetches the new hash, the upstream 404s, and the page breaks.

The correct upload sequence:

  1. Upload all fingerprinted assets first (rsync --exclude='*.html').
  2. Confirm all hashed files are accessible (spot-check with curl -sI).
  3. Upload the new index.html.
  4. Issue the Nginx purge for index.html.

Step 4 is optional if the proxy_cache_valid TTL for HTML is short enough (10 minutes). The delay is only visible to users who happen to hit Nginx in the window between old HTML expiring and new HTML arriving. For zero-window atomicity, use an atomic symlink swap on the origin and trigger the purge after the swap.

Performance Impact

Nginx proxy cache reduces latency from upstream application server response time (often 10–200 ms) to Nginx disk read time (0.1–2 ms for warm cache on SSD). RAM-backed cache filesystems (tmpfs) push this below 0.1 ms. Cache hit ratios for fingerprinted assets approach 100% because the URL never changes once deployed; only the very first request after a deploy misses.

Build time is not affected. Hash length — 8 hex characters by default, 12–16 for large monorepos with thousands of chunks — affects URL length and filesystem path depth, not Nginx performance.

Measuring Cache Effectiveness

Use Nginx’s built-in log format to compute hit ratios from access logs:

http {
    log_format cache_log '$remote_addr - $upstream_cache_status '
                         '"$request" $status $body_bytes_sent '
                         '"$http_referer" "$http_user_agent"';
    access_log /var/log/nginx/cache.log cache_log;
}

After 24 hours of traffic, extract the hit/miss breakdown:

awk '{print $3}' /var/log/nginx/cache.log | sort | uniq -c | sort -rn

A healthy fingerprinted-asset workload shows HIT at 95%+ and MISS at under 5% (first-load per new deploy only). A high BYPASS count means cookie-based bypass conditions are firing unexpectedly. A high EXPIRED count means proxy_cache_valid is set too short for the traffic pattern.

Sizing the Cache Zone

The keys_zone memory allocation in proxy_cache_path holds cache metadata (keys, TTLs, flags) but not response bodies. Response bodies are stored on disk. A conservative estimate: 1 MB of keys_zone holds approximately 8,000 cache entries. For a site with 500 fingerprinted assets and 50 HTML pages, 10 MB is generous. For a monorepo with 5,000 chunks, increase to 64 MB.

Monitor zone utilization with the Nginx stub status module or a Prometheus exporter. When the zone fills, Nginx evicts entries using an LRU policy — the zone size does not cap disk usage, the max_size parameter does.

Pre-deploy checklist:

  • proxy_cache_path directory created and writable by the Nginx worker user
  • keys_zone name consistent across proxy_cache_path, proxy_cache, and proxy_cache_purge
  • proxy_cache_key includes $host for multi-vhost deployments
  • location uses regex matching 8+ hex chars and adds immutable header
  • location sets proxy_cache_valid 200 10m and short no-cache browser TTL
  • allow 127.0.0.1; allow 10.0.0.0/8; deny all;
  • proxy_hide_header Cache-Control present so upstream headers do not override Nginx headers
  • X-Cache-Status $upstream_cache_status header added for live debug visibility
  • open_file_cache configured if disk-served static files are used alongside proxy cache

Frequently Asked Questions

Do I need to purge the cache when I deploy a new version of a fingerprinted asset?

No. Because the content hash is part of the filename, a new asset has a new URL. The old cached entry for the previous URL becomes unreachable — it will eventually be evicted by the inactive timeout. Only the HTML entry point, which references the new hashed filename, needs to be purged.

What does X-Cache-Status: BYPASS mean and how do I fix it?

BYPASS occurs when a proxy_cache_bypass condition evaluates to a non-zero or non-empty string. Common culprits are Cookie or Authorization headers in the request. For public static assets, bypass conditions should never fire. Add proxy_cache_bypass 0; explicitly, or audit your map blocks and upstream Set-Cookie headers to ensure they are not inadvertently activating bypass.

Can I use Nginx proxy cache alongside a cloud CDN like Cloudflare or CloudFront?

Yes. Nginx acts as the origin server from the CDN’s perspective. The CDN caches Nginx’s response at the edge; Nginx caches the upstream application’s response locally. The two caches operate independently. Fingerprinted assets propagate immutability through both layers because the Cache-Control: public, max-age=31536000, immutable header is sent by Nginx and honoured by the CDN. The Cloudflare cache rules guide and the Cache-Control immutable and TTL tuning guide explain how to configure each layer consistently.

How do I choose between proxy_cache_valid and relying on upstream Cache-Control headers?

proxy_cache_valid sets the TTL unconditionally by HTTP status code, ignoring upstream Cache-Control. This is the safer default for fingerprinted assets because it prevents an accidentally set Cache-Control: max-age=3600 on the upstream from reducing your 365-day proxy cache lifetime. For mutable resources, use a short proxy_cache_valid 200 10m and let Nginx revalidate with the upstream via proxy_cache_revalidate on.