Performance

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.

· 14 min read · How we verify this

Key points

  • A shared cache obeys a different directive set from a browser: s-maxage and proxy-revalidate apply only to it, and private forbids it from storing at all.
  • nginx's proxy_cache_key defaults to $scheme$proxy_host$request_uri, which contains the upstream name and not the client's Host, so several virtual hosts sharing one upstream collide.
  • proxy_cache_bypass stops nginx serving from cache but still lets it store the response. Only proxy_no_cache prevents 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#

DirectiveEffect on a browser (private cache)Effect on a shared cache
publicLittle practical effectPermits storage in cases otherwise forbidden, notably responses to requests with Authorization
privateStorable and reusableMust not store
no-storeMust not storeMust not store
no-cacheMay store; must revalidate before every reuseSame: store permitted, reuse requires successful validation
max-age=NFresh for N secondsFresh for N seconds, unless s-maxage overrides
s-maxage=NIgnoredFresh for N seconds, overrides max-age, and also implies proxy-revalidate
must-revalidateMust not serve stale once expiredMust not serve stale once expired
proxy-revalidateIgnoredMust not serve stale once expired (leaves browsers free to)
stale-while-revalidate=NSupported unevenlyServe stale for up to N seconds after expiry while revalidating in the background
stale-if-error=NSupported unevenlyServe stale for up to N seconds when the origin errors or times out
immutableSkip revalidation on reload while freshNo defined effect; harmless to send
must-understandPairs with no-store: only store if the status code semantics are understoodSame

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:

nginx
proxy_cache_key "$scheme$host$request_uri";

Three further sources of fragmentation cost hit rate rather than correctness:

  • Query parameter order. ?a=1&b=2 and ?b=2&a=1 are 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, fbclid and 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 a map on $args.
  • Trailing slashes and case. /Path and /path are 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-Encoding is necessary and manageable once you normalise the value, as described in compression through proxies.
  • Vary: User-Agent is 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: Cookie is nearly as bad, since any analytics cookie perturbs the value. If a response genuinely depends on a cookie, it is user-specific and should be private instead.
  • Vary: * means the response is uncacheable by a shared cache. nginx will not cache such a response (nginx has honoured Vary in proxy_cache since 1.7.7).

nginx proxy_cache: the directives and their defaults#

DirectiveDefaultWhat it governs
proxy_cache_path ... keys_zone=name:sizenoneShared memory for keys and metadata. Roughly 8,000 keys per megabyte, so keys_zone=cache:100m holds about 800,000
... inactive=10mRemoves entries not accessed within this time, regardless of remaining freshness
... max_size=unlimitedUpper bound on on-disk size; the cache manager evicts least recently used entries to stay under it
... use_temp_path=onWith on, files are written to proxy_temp_path then moved, which is a cross-filesystem copy if the paths differ. Set off
proxy_cache_validnoneTTL by status code. Without it, nothing is cached unless the upstream sends Expires or Cache-Control
proxy_cache_min_uses1Number of requests before a response is stored
proxy_cache_methodsGET HEADMethods eligible for caching
proxy_cache_lockoffSerialises concurrent misses on one key
proxy_cache_lock_timeout5sHow long a waiting request waits before going to the upstream itself
proxy_cache_lock_age5sHow long the lock holder gets before another request is allowed to try
proxy_cache_use_staleoffConditions under which a stale entry may be served
proxy_cache_background_updateoffRefresh a stale entry in the background while the stale copy is served
proxy_cache_revalidateoffUse If-Modified-Since and If-None-Match on expiry instead of refetching
proxy_cache_bypassnoneConditions under which the cache is not read. Storage still happens
proxy_no_cachenoneConditions 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.

ValueMeaning
MISSNot in cache; fetched from upstream and (if permitted) stored
BYPASSA proxy_cache_bypass condition matched, so the cache was not consulted
EXPIREDPresent but stale; a fresh copy was fetched
STALEStale copy served under proxy_cache_use_stale
UPDATINGStale copy served while another request refreshes the entry
REVALIDATEDConditional request returned 304; the stored copy was reused
HITServed 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#

nginx
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:

  1. Responses to authenticated requests. RFC 9111 forbids a shared cache from storing a response to a request with an Authorization header unless the response carries public, must-revalidate or s-maxage. Cookie-based sessions get no such protection from the specification, so you must express it in configuration.
  2. Responses carrying Set-Cookie. A cached Set-Cookie is replayed to every subsequent client, handing them the first user's session. nginx declines to cache these by default. Do not undo that with proxy_ignore_headers Set-Cookie;, and do not "fix" the symptom with proxy_hide_header Set-Cookie;, which suppresses the evidence while leaving the body cached.
  3. Anything whose content depends on an input that is not in the key. X-Forwarded-Host, X-Original-URL and 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:

nginx
# 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#

StrategyReliabilityCostWhere it fits
Short TTLBounded staleness only, never immediateOrigin load proportional to 1/TTLDefault for HTML and API responses that tolerate a delay
Surrogate keys / cache tagsHigh, if every entry is tagged correctlyRequires a cache that supports itFastly Surrogate-Key, Varnish xkey, Cloudflare Cache-Tag. Not available in open source nginx
Explicit purge by URLOnly as good as your list of key variantsMust fan out to every node and every PoPnginx Plus proxy_cache_purge, or the third-party ngx_cache_purge module
Versioned / fingerprinted URLsTotalRequires a build step and HTML that references the hashed nameStatic 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.

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.

  1. RFC 9111 HTTP Caching
  2. RFC 9111 section 5.2, Cache-Control response directives
  3. RFC 5861 HTTP Cache-Control Extensions for Stale Content
  4. RFC 8246 HTTP Immutable Responses
  5. RFC 9110 HTTP Semantics
  6. nginx ngx_http_proxy_module
  7. nginx ngx_http_upstream_module
  8. Varnish vmod_xkey surrogate keys
  9. ngx_cache_purge module
  10. 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.

More in performance and protocols#