Nginx Caching
proxy_cache makes nginx serve cached upstream responses without bothering your Python app. Cuts backend load dramatically for hot read endpoints. Configurable per-location, with sane fallbacks for stampedes and stale-while-revalidate.
Basic setup
http {
# Define a cache zone — must be in `http` context
proxy_cache_path /var/cache/nginx/api
levels=1:2
keys_zone=api_cache:50m
max_size=2g
inactive=60m
use_temp_path=off;
server {
location /api/items {
proxy_pass http://backend;
proxy_cache api_cache;
proxy_cache_valid 200 5m; # cache 200 responses for 5 minutes
proxy_cache_valid 404 1m; # cache 404s briefly
proxy_cache_key "$scheme$request_method$host$request_uri";
add_header X-Cache-Status $upstream_cache_status; # HIT / MISS / BYPASS / STALE
}
}
}
proxy_cache_path parameters
| Parameter | Meaning |
|---|---|
| path | filesystem dir for cached objects |
levels=1:2 |
nest cache files in 1-char then 2-char subdirs (avoids huge flat dir) |
keys_zone=name:size |
shared memory zone storing the cache index (50m ≈ ~400k keys) |
max_size=Ng |
hard cap on disk usage; LRU eviction when exceeded |
inactive=60m |
objects unused for this long are removed even if cache isn’t full |
use_temp_path=off |
write directly to cache dir instead of staging — faster |
Cache key
Default: $scheme$proxy_host$request_uri. Customize for your needs:
proxy_cache_key "$scheme$request_method$host$request_uri";
# Cache per-language:
proxy_cache_key "$scheme$request_uri$http_accept_language";
# Cache per-user (almost never useful — kills hit rate):
proxy_cache_key "$scheme$request_uri$cookie_session";
Make the key include everything that varies the response. Forgetting $request_method means a POST gets cached and returned for subsequent GETs (catastrophic).
What gets cached
By default nginx only caches GET and HEAD with status 200/301/302. Override:
proxy_cache_methods GET HEAD POST; # add POST (rarely correct)
proxy_cache_valid 200 301 302 5m;
proxy_cache_valid 404 1m;
proxy_cache_valid any 30s; # default for everything else
Honor upstream cache headers (Cache-Control: max-age=N, Expires) — proxy_cache_valid is the fallback when upstream doesn’t say.
What does NOT get cached
By default nginx skips caching when:
- Upstream sends
Set-Cookie(assumed user-specific). - Upstream sends
Cache-Control: no-cache/private/no-store. - Upstream sends
Vary: *. - Request has
Cache-Control: no-cacheorPragma: no-cachefrom client.
To override (e.g. cache despite Set-Cookie):
proxy_ignore_headers Set-Cookie;
proxy_hide_header Set-Cookie; # don't leak the cookie to other users
Be very careful — wrong here = cross-user data leakage.
X-Cache-Status for debugging
add_header X-Cache-Status $upstream_cache_status always;
Possible values:
| Status | Means |
|---|---|
HIT |
served from cache |
MISS |
not in cache; fetched from upstream and cached |
BYPASS |
request bypassed cache (per proxy_cache_bypass) |
EXPIRED |
cached but expired; refetched |
STALE |
cached and expired but served stale (per proxy_cache_use_stale) |
UPDATING |
served stale while a refresh is in progress |
REVALIDATED |
upstream confirmed cached version still valid (304) |
- |
not configured for caching |
curl -I to check. Keep this header in production for debugging; remove in CDN if you don’t want to leak it.
Stale-while-revalidate (the cache stampede solution)
When 1000 requests for the same URL arrive simultaneously and the cached version just expired, naive caches stampede the upstream. Nginx prevents this:
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
| Directive | What it does |
|---|---|
proxy_cache_use_stale updating |
when upstream is being asked, serve stale to other concurrent requests |
proxy_cache_use_stale error timeout http_500 ... |
when upstream is dead, serve stale to keep the site up |
proxy_cache_background_update on |
refresh cache in background while serving stale |
proxy_cache_lock on |
only one request at a time goes to the upstream for a given key (cache lock) |
proxy_cache_lock_timeout 5s |
how long others wait on the lock before going themselves |
This combination = “the upstream is hit at most once per key per cache period, even if 1000 clients hit at once.”
Microcaching — the cheap big win
For dynamic content that changes every few seconds at most:
proxy_cache_valid 200 1s; # cache for ONE SECOND
proxy_cache_use_stale updating;
proxy_cache_lock on;
A 1-second cache on a 10k RPS endpoint reduces upstream traffic to ~1 RPS. For a busy news/social timeline endpoint that updates every few seconds, microcaching is a near-free 99.9% reduction. Almost no user notices.
Caveat: doesn’t work if responses are truly per-user (cookie-driven, JWT-driven). Microcache only the public, anon variants (gate with proxy_cache_bypass).
Per-request bypass
proxy_cache_bypass $cookie_nocache $arg_nocache $http_authorization;
Skip cache when:
- Cookie
nocacheis set. - Query param
nocacheis present. Authorizationheader is present (logged-in users get fresh content).
Purging (manual invalidation)
Open-source nginx doesn’t ship a purge command. Options:
- Delete from disk.
rm -rf /var/cache/nginx/apiand reload nginx. Crude but works. ngx_cache_purgethird-party module — adds aproxy_cache_purgedirective.- NGINX Plus —
proxy_cache_purgebuilt in. - Wait it out — if TTLs are short (minutes), just wait.
- Versioned URLs — change
?v=2to invalidate via cache key change.
Option 5 is the cleanest pattern for app-controlled invalidation.
FastCGI cache (PHP / FastCGI apps)
Same model, different prefix:
fastcgi_cache_path ...;
fastcgi_cache_key ...;
fastcgi_cache zone_name;
fastcgi_cache_valid 200 5m;
Equivalent for proxy_cache when the upstream is FastCGI (php-fpm).
Memory-only caching (faster but small)
By design proxy_cache writes to disk. For pure RAM caching, options:
- Use a tmpfs (memory-backed filesystem) for the cache path.
- For a few hot pages:
proxy_cache_path /dev/shm/nginx_cache ....
Tradeoff: cache disappears on reboot; size limited by RAM.
Common pitfalls
- Caching POST responses without explicitly opting in (and accepting the consequences). Don’t.
- Cache key without
$request_method— POST responses returned to GET clients. - Not setting
Vary: Accept-Encodingwhen caching gzipped content — un-gzipped clients get garbage. - Caching responses with
Set-Cookieby overridingproxy_ignore_headers Set-Cookie— withoutproxy_hide_header Set-Cookie, you leak one user’s session to everyone. - Forgot
proxy_cache_lock— first request takes 100ms, the next 999 in that 100ms window all bypass cache and hit upstream. - TTL too long for fast-changing data — users see stale prices/inventory. Combine short TTL + stale-while-revalidate.
Common interview confusions
- “Caching means the upstream is hit once per cache period.” — only with
proxy_cache_lock. Without it, concurrent requests can all bypass the cache before the first finishes. - “
proxy_cacheworks for any HTTP method.” — defaults to GET/HEAD. POST etc. require explicit opt-in. - “Nginx automatically obeys
Cache-Control: no-store.” — yes by default. Surprises happen when you’ve addedproxy_ignore_headers Cache-Control.
Interview angle
- “How would you cache responses with nginx?” —
proxy_cache_pathto define a zone,proxy_cache <zone>in the location,proxy_cache_valid 200 5m;for TTL, customproxy_cache_keyif defaults don’t match what varies the response. - “What’s microcaching?” — caching dynamic content for very short windows (1–5s). On a 10k RPS endpoint, a 1s cache reduces backend load to ~1 RPS while users see content that’s at most 1 second stale.
- “How do you avoid cache stampede?” —
proxy_cache_lock onso only one request fetches per key;proxy_cache_use_stale updatingso others get stale while it does;proxy_cache_background_update onso refresh happens in background. - “How do you invalidate cached entries?” — versioned URLs (best); third-party
ngx_cache_purgemodule; NGINX Plus’s purge directive; or just delete files from/var/cache/nginx/. - “What does
X-Cache-Statustell you?” — HIT, MISS, BYPASS, EXPIRED, STALE, UPDATING — useful for debugging cache behavior in production. - “Why is
proxy_cache_keyimportant?” — it determines what counts as “the same request.” Missing$request_methodor relevant headers can cause one user’s data being served to another.