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_revalidatesupport). ngx_cache_purgemodule compiled in, or Nginx Plus R14+ for the commercialproxy_cache_purgedirective. The open-source module is available via thelibnginx-mod-http-cache-purgepackage 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 thenginxworker 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;
}
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:
- Upload all fingerprinted assets first (
rsync --exclude='*.html'). - Confirm all hashed files are accessible (spot-check with
curl -sI). - Upload the new
index.html. - 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_pathdirectory created and writable by the Nginx worker userkeys_zonename consistent acrossproxy_cache_path,proxy_cache, andproxy_cache_purgeproxy_cache_keyincludes$hostfor multi-vhost deploymentslocationuses regex matching 8+ hex chars and addsimmutableheaderlocationsetsproxy_cache_valid 200 10mand shortno-cachebrowser TTLallow 127.0.0.1; allow 10.0.0.0/8; deny all;proxy_hide_header Cache-Controlpresent so upstream headers do not override Nginx headersX-Cache-Status $upstream_cache_statusheader added for live debug visibilityopen_file_cacheconfigured 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.
Related
- CDN Purge Strategies — parent overview of all CDN and reverse proxy caching approaches
- Immutable assets vs proxy cache purge — when to never purge vs when purging is unavoidable
- Cache-Control immutable and TTL tuning — header-level strategy for coordinating browser, proxy, and CDN TTLs
- Cloudflare cache rules and purge — equivalent configuration for Cloudflare CDN
- Cache key architecture — design principles for proxy cache keys and fingerprinting strategies