Caching in reverse proxies
RFC 9111 semantics at a shared cache, nginx proxy_cache defaults, cache key design, invalidation strategies and the misconfiguration that leaks user data.
Key points
- A shared cache obeys a different directive set from a browser:
s-maxageandproxy-revalidateapply only to it, andprivateforbids it from storing at all. - nginx's
proxy_cache_keydefaults to$scheme$proxy_host$request_uri, which contains the upstream name and not the client'sHost, so several virtual hosts sharing one upstream collide. proxy_cache_bypassstops nginx serving from cache but still lets it store the response. Onlyproxy_no_cacheprevents storage, and using one without the other is how private responses leak.inactive(default 10 minutes) evicts entries that have not been accessed, independently of freshness, so a 24-hour TTL on a cold URL never survives.
A reverse proxy cache is a shared cache in RFC 9111 terms, which means it stores one representation and serves it to many users. That single property determines everything else: it must honour private and no-store by refusing to store, it must prefer s-maxage over max-age, it must not store responses to requests carrying Authorization unless explicitly permitted, and it must key entries precisely enough that two different users can never collide on one entry. A private browser cache has none of these obligations, so directives tuned for browsers say nothing useful about what your proxy will do.
The two decisions that matter are the cache key (what counts as the same response) and the freshness lifetime (how long you will serve it without asking). Invalidation is a distant third, because only one invalidation strategy is actually reliable.
Cache-Control directives at a shared cache#
| Directive | Effect on a browser (private cache) | Effect on a shared cache |
|---|---|---|
public | Little practical effect | Permits storage in cases otherwise forbidden, notably responses to requests with Authorization |
private | Storable and reusable | Must not store |
no-store | Must not store | Must not store |
no-cache | May store; must revalidate before every reuse | Same: store permitted, reuse requires successful validation |
max-age=N | Fresh for N seconds | Fresh for N seconds, unless s-maxage overrides |
s-maxage=N | Ignored | Fresh for N seconds, overrides max-age, and also implies proxy-revalidate |
must-revalidate | Must not serve stale once expired | Must not serve stale once expired |
proxy-revalidate | Ignored | Must not serve stale once expired (leaves browsers free to) |
stale-while-revalidate=N | Supported unevenly | Serve stale for up to N seconds after expiry while revalidating in the background |
stale-if-error=N | Supported unevenly | Serve stale for up to N seconds when the origin errors or times out |
immutable | Skip revalidation on reload while fresh | No defined effect; harmless to send |
must-understand | Pairs with no-store: only store if the status code semantics are understood | Same |
The pairing that does most of the work in practice is Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=60, stale-if-error=86400: browsers revalidate every time, the shared cache absorbs the load for ten minutes, one request per key pays the revalidation cost, and an origin outage is invisible for a day.
The cache key and why it fragments#
RFC 9111 defines the primary cache key as the request method plus the target URI, refined by a secondary key derived from Vary. Every implementation extends this, and the defaults are where the surprises live.
nginx's proxy_cache_key defaults to $scheme$proxy_host$request_uri (before 1.7.9 it was $scheme$proxy_host$uri$is_args$args). Read $proxy_host carefully: it is the name and port of the upstream from the proxy_pass directive, not the client's Host header. One nginx serving a.example.com and b.example.com from the same upstream block produces identical keys for identical paths, and the second hostname is served the first hostname's content. The fix is one line:
proxy_cache_key "$scheme$host$request_uri";Three further sources of fragmentation cost hit rate rather than correctness:
- Query parameter order.
?a=1&b=2and?b=2&a=1are different keys everywhere, because no cache sorts them. If clients generate parameters from an unordered map, hit rate collapses for no visible reason. - Tracking parameters.
utm_source,gclid,fbclidand friends never change the response but always change the key. Every distinct campaign link is a separate cache entry for the same bytes. Strip them from the key (not from the request, since the application may log them) with amapon$args. - Trailing slashes and case.
/Pathand/pathare distinct keys even where the application treats them identically.
Vary is the correctness-preserving mechanism and the biggest hit-rate hazard. Each distinct value of each listed header creates a separate stored variant.
Vary: Accept-Encodingis necessary and manageable once you normalise the value, as described in compression through proxies.Vary: User-Agentis the classic mistake. The header is effectively unique per browser build, so the variant count approaches the visitor count and the cache degrades into a very expensive pass-through. Frameworks emit it automatically when they do device detection; check for it before blaming the cache.Vary: Cookieis nearly as bad, since any analytics cookie perturbs the value. If a response genuinely depends on a cookie, it is user-specific and should beprivateinstead.Vary: *means the response is uncacheable by a shared cache. nginx will not cache such a response (nginx has honouredVaryinproxy_cachesince 1.7.7).
nginx proxy_cache: the directives and their defaults#
| Directive | Default | What it governs |
|---|---|---|
proxy_cache_path ... keys_zone=name:size | none | Shared memory for keys and metadata. Roughly 8,000 keys per megabyte, so keys_zone=cache:100m holds about 800,000 |
... inactive= | 10m | Removes entries not accessed within this time, regardless of remaining freshness |
... max_size= | unlimited | Upper bound on on-disk size; the cache manager evicts least recently used entries to stay under it |
... use_temp_path= | on | With on, files are written to proxy_temp_path then moved, which is a cross-filesystem copy if the paths differ. Set off |
proxy_cache_valid | none | TTL by status code. Without it, nothing is cached unless the upstream sends Expires or Cache-Control |
proxy_cache_min_uses | 1 | Number of requests before a response is stored |
proxy_cache_methods | GET HEAD | Methods eligible for caching |
proxy_cache_lock | off | Serialises concurrent misses on one key |
proxy_cache_lock_timeout | 5s | How long a waiting request waits before going to the upstream itself |
proxy_cache_lock_age | 5s | How long the lock holder gets before another request is allowed to try |
proxy_cache_use_stale | off | Conditions under which a stale entry may be served |
proxy_cache_background_update | off | Refresh a stale entry in the background while the stale copy is served |
proxy_cache_revalidate | off | Use If-Modified-Since and If-None-Match on expiry instead of refetching |
proxy_cache_bypass | none | Conditions under which the cache is not read. Storage still happens |
proxy_no_cache | none | Conditions under which the response is not stored |
nginx also refuses to cache on its own initiative: a response carrying Set-Cookie is not cached, and Cache-Control containing no-cache, no-store, private or max-age=0 disables caching. proxy_ignore_headers overrides all of that, which is exactly why it is dangerous.
$upstream_cache_status#
Expose it as a response header and you can diagnose a cache from the client side.
| Value | Meaning |
|---|---|
MISS | Not in cache; fetched from upstream and (if permitted) stored |
BYPASS | A proxy_cache_bypass condition matched, so the cache was not consulted |
EXPIRED | Present but stale; a fresh copy was fetched |
STALE | Stale copy served under proxy_cache_use_stale |
UPDATING | Stale copy served while another request refreshes the entry |
REVALIDATED | Conditional request returned 304; the stored copy was reused |
HIT | Served from cache with no upstream contact |
A deployment where every response is MISS almost always means the upstream is emitting Set-Cookie or a Cache-Control that forbids storage, not that the cache is broken.
Worked configuration#
proxy_cache_path /var/cache/nginx/content
levels=1:2
keys_zone=content:100m
max_size=20g
inactive=24h
use_temp_path=off;
map $http_cookie $has_session {
default 0;
"~*(^|;\s*)sid=" 1;
}
server {
location / {
proxy_cache content;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_valid 200 301 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_valid any 0;
proxy_cache_min_uses 2;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_lock_age 5s;
proxy_cache_revalidate on;
proxy_cache_background_update on;
proxy_cache_use_stale error timeout updating
http_500 http_502 http_503 http_504;
# Both directives, always together
proxy_cache_bypass $has_session $http_authorization;
proxy_no_cache $has_session $http_authorization;
add_header X-Cache-Status $upstream_cache_status always;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_pass http://app_pool;
}
}inactive=24h is set deliberately against the 10-minute default, because with proxy_cache_valid ... 10m and the default inactive=10m a URL requested once every fifteen minutes is evicted before it is ever reused and reports MISS forever. proxy_cache_min_uses 2 keeps one-off crawler URLs out of the store. proxy_cache_valid any 0; makes the policy explicit: statuses you did not name are not cached.
proxy_cache_lock on is the thundering-herd control. On a cold key, the first request goes upstream and the rest wait on the lock rather than each generating an origin request; without it, a popular page expiring during a traffic peak sends every concurrent request to the application at once. proxy_cache_lock_timeout bounds that wait, so a slow origin degrades into a herd rather than into a queue, which is the correct trade and one to check against your wider timeout budgets.
proxy_cache_background_update on combined with proxy_cache_use_stale updating gives the stale-while-revalidate behaviour locally: the stale copy is returned immediately (X-Cache-Status: UPDATING) while a background subrequest refreshes it.
What must never be cached#
Three categories, in order of how often they are got wrong:
- Responses to authenticated requests. RFC 9111 forbids a shared cache from storing a response to a request with an
Authorizationheader unless the response carriespublic,must-revalidateors-maxage. Cookie-based sessions get no such protection from the specification, so you must express it in configuration. - Responses carrying
Set-Cookie. A cachedSet-Cookieis replayed to every subsequent client, handing them the first user's session. nginx declines to cache these by default. Do not undo that withproxy_ignore_headers Set-Cookie;, and do not "fix" the symptom withproxy_hide_header Set-Cookie;, which suppresses the evidence while leaving the body cached. - Anything whose content depends on an input that is not in the key.
X-Forwarded-Host,X-Original-URLand similar headers are frequently reflected into responses and are almost never part of the cache key, which is the mechanism behind web cache poisoning. Either include the header in the key or stop reflecting it, and strip client-supplied forwarding headers at the edge as covered in the reverse proxy security checklist.
The canonical leak configuration looks reasonable at a glance:
# Do not do this
proxy_ignore_headers Cache-Control Expires Set-Cookie;
proxy_cache_valid 200 10m;
proxy_cache_key "$scheme$proxy_host$request_uri";Every safeguard has been removed at once. The origin's Cache-Control: private on /account is ignored, the Set-Cookie that would have prevented storage is ignored, the key contains no user-distinguishing element, and the TTL is unconditional. Ten minutes of one user's account page is served to everyone who asks.
Invalidation strategies, honestly ranked#
| Strategy | Reliability | Cost | Where it fits |
|---|---|---|---|
| Short TTL | Bounded staleness only, never immediate | Origin load proportional to 1/TTL | Default for HTML and API responses that tolerate a delay |
| Surrogate keys / cache tags | High, if every entry is tagged correctly | Requires a cache that supports it | Fastly Surrogate-Key, Varnish xkey, Cloudflare Cache-Tag. Not available in open source nginx |
| Explicit purge by URL | Only as good as your list of key variants | Must fan out to every node and every PoP | nginx Plus proxy_cache_purge, or the third-party ngx_cache_purge module |
| Versioned / fingerprinted URLs | Total | Requires a build step and HTML that references the hashed name | Static assets, always |
Purge is weaker than it looks. You must reconstruct every key that could hold the object, including each Vary variant and each scheme, then deliver the purge to every cache node before any of them serves the stale copy. A missed variant is invisible until a user complains. Surrogate keys solve the enumeration problem (tag a response product-4711 and purge that tag) but not the distribution problem.
Versioned URLs are the only fully reliable option because they invert the problem: a new URL cannot have a stale entry, so nothing needs invalidating. Pair a content hash in the filename with Cache-Control: public, max-age=31536000, immutable and never purge again. This is why the practical architecture is versioned URLs with a one-year TTL for assets and a short s-maxage with stale-while-revalidate for the HTML that references them.
Failure modes#
Everything is MISS. Check for Set-Cookie on responses first: a session middleware that issues a cookie on every request, including anonymous ones, disables caching entirely. Then check Cache-Control, then check whether proxy_cache_valid covers the status code being returned. Add X-Cache-Status and $upstream_cache_status to the access log format so this is one query rather than one experiment.
Cold entries that never warm up. proxy_cache_valid 24h with the default inactive=10m means an entry is removed if it goes ten minutes without a request, no matter how long it remains fresh. Long-tail URLs never accumulate hits. Set inactive to at least the longest TTL you intend to serve.
A cache stampede at a round number. Everything deployed at once shares one TTL and expires at once, sending a synchronised wave to the origin, typically presenting as 502 or 504 errors at exactly TTL-length intervals. Fix with proxy_cache_lock on, proxy_cache_use_stale updating, proxy_cache_background_update on, and jitter in the origin's s-maxage.
The disk fills. max_size is unlimited by default. The cache manager only evicts to satisfy max_size and inactive, so without max_size the cache grows until the filesystem is full and nginx starts failing writes. Always set it, with headroom below the partition size.
ignore long locked inactive cache entry in the error log. A worker died while holding a cache node lock, and the manager is stepping over the entry. Occasional occurrences follow a worker crash; a steady stream means workers are being killed repeatedly, so look at OOM and segfault evidence rather than at the cache configuration.
Hit rate collapsed after a framework upgrade. Look for a newly emitted Vary. Device-detection and content-negotiation middleware commonly add Vary: User-Agent or Vary: Cookie, and the cache is then storing a variant per visitor while behaving, from the outside, exactly like a working cache.
Compressed and uncompressed variants served to the wrong clients. The cache key omits the encoding and the upstream omits Vary: Accept-Encoding. Include a normalised encoding in proxy_cache_key; this and the wider interaction are covered in compression through proxies.
Frequently asked questions#
What is the difference between max-age and s-maxage?#
max-age sets the freshness lifetime for every cache; s-maxage sets it for shared caches only and takes precedence there. Use s-maxage to let a CDN or reverse proxy hold a response for minutes while browsers revalidate on every request, which is the standard pattern for HTML you want to be able to change quickly.
Why is nginx not caching anything even though proxy_cache is set?#
The most common cause is a Set-Cookie header on the response, which makes nginx decline to cache by default. The next most common is the absence of proxy_cache_valid combined with an upstream that sends no Expires or Cache-Control, in which case nginx has no TTL to apply. Log $upstream_cache_status and inspect the upstream response headers before changing anything else.
Does proxy_cache_bypass stop nginx from storing the response?#
No. proxy_cache_bypass only prevents nginx from reading an existing entry; the response is still written to the cache if it is otherwise cacheable. To prevent storage you need proxy_no_cache with the same conditions. Using one without the other is a common way for authenticated responses to end up served to anonymous users.
How do I stop a thundering herd when a popular key expires?#
Enable proxy_cache_lock on; so only one request per key goes to the upstream while the others wait, and add proxy_cache_use_stale updating; with proxy_cache_background_update on; so waiting clients receive the stale copy immediately instead of blocking. Tune proxy_cache_lock_timeout to a value below your upstream read timeout.
What does X-Cache-Status: UPDATING mean?#
It means nginx served a stale cached copy while a background or concurrent request refreshes that entry. It appears when proxy_cache_use_stale updating is configured, usually alongside proxy_cache_background_update on. It is a healthy status: the client got a fast response and the entry is being renewed.
Can I purge a single URL from the nginx cache?#
Not with open source nginx alone. proxy_cache_purge is an nginx Plus feature, and the third-party ngx_cache_purge module provides equivalent behaviour if you can rebuild nginx. Otherwise, delete the cache file directly by computing the MD5 of your proxy_cache_key and locating it under the levels directory structure, which works but is fragile.
Is Vary: User-Agent ever the right answer?#
Almost never. It creates a stored variant for effectively every browser build, so the cache stores enormous numbers of near-identical entries and hits almost nothing. If a response genuinely differs by device, serve responsive markup instead, or vary on a small normalised header (such as Sec-CH-UA-Mobile) with a handful of possible values.
Should authenticated pages ever be cached at a reverse proxy?#
Only with a key that includes the user identity, and only when the operational benefit justifies the risk, which is rare. RFC 9111 already forbids a shared cache from storing responses to Authorization-bearing requests unless the response opts in, and cookie-based sessions need the same rule expressed explicitly through proxy_no_cache. The safer pattern is to cache the shared fragments and assemble the personalised parts at the origin.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- RFC 9111 HTTP Caching
- RFC 9111 section 5.2, Cache-Control response directives
- RFC 5861 HTTP Cache-Control Extensions for Stale Content
- RFC 8246 HTTP Immutable Responses
- RFC 9110 HTTP Semantics
- nginx ngx_http_proxy_module
- nginx ngx_http_upstream_module
- Varnish vmod_xkey surrogate keys
- ngx_cache_purge module
- W3C Edge Architecture Specification (Surrogate-Control)
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.