Troubleshooting

Why is nginx proxy_cache always MISS?

Every request shows X-Cache-Status MISS. The response headers, config and request details that stop nginx storing it, each tested, and how to find yours in one look.

· 18 min read · How we verify this

Key points

  • A response that is MISS on every request was refused at storage time. In order of how often it happens: a Set-Cookie header, a Cache-Control with private, no-cache, no-store or max-age=0, and no proxy_cache_valid for a response that carries no caching headers of its own. All three gave MISS forever on nginx 1.24.0, 1.27.5, 1.28.3 and 1.30.6.
  • nginx reads Cache-Control by substring. no-cache="Set-Cookie" counts as no-cache, s-maxage=0 wins over max-age=600, and a quoted max-age="60" disables caching even with proxy_cache_valid set. A Vary value longer than 128 bytes is never cached.
  • proxy_buffering off, X-Accel-Buffering: no from the backend, a 404 under proxy_cache_valid 10m (which covers only 200, 301 and 302), and an unwritable cache directory also give MISS every time. The last one is the only cause in this list that writes an error log line by default.
  • An empty X-Cache-Status is a different fault: the cache never ran. A regex location without proxy_cache, a POST, or a cache key longer than proxy_buffer_size allows (3,494 bytes passed, 3,495 failed at the 4k default) all left $upstream_cache_status empty.
  • What the client sends rarely matters. A browser's Cache-Control: no-cache, a Cookie and an Authorization header were all ignored: nginx served one user's authorised response to an anonymous request as a HIT.

nginx shows MISS on every request when it fetched the response but decided not to store it, and nine times in ten the backend told it not to: the response carries a Set-Cookie header, or a Cache-Control containing private, no-cache, no-store or max-age=0. The third common cause is on the nginx side: there is no proxy_cache_valid line and the response has no Cache-Control or Expires of its own, so nginx has no lifetime to give it. Look at the backend's response headers first:

bash
curl -s -o /dev/null -D - http://backend:8000/page | grep -iE 'set-cookie|cache-control|expires|vary|x-accel'

Whatever that prints decides the row below. Every row was tested; MISS means three requests in a row reached the backend.

What the response or config hasResultFix
Set-CookieMISS foreverStop issuing the cookie on public pages, or ignore and hide it (see below)
Cache-Control: private, no-cache, no-store or max-age=0MISS foreverSend public, s-maxage=N from the app for pages that are the same for everyone
No caching headers, no proxy_cache_validMISS foreverproxy_cache_valid 200 10m;
Expires in the past, or Expires: 0MISS foreverRemove it, or send Cache-Control: max-age=N, which overrides it
X-Accel-Expires: 0MISS foreverRemove it from the backend
Vary: *, or a Vary value over 128 bytesMISS foreverShorten Vary to what the response really depends on
404, 500 or other status under proxy_cache_valid 10mMISS foreverList the codes: proxy_cache_valid any 1m;
proxy_buffering off, or X-Accel-Buffering: no from the backendMISS foreverBuffering must be on for the cache to store anything
proxy_cache_min_uses 3MISS three times, then HITWorking as configured
proxy_cache_bypass matchedBYPASSThe condition is true for your requests (often a cookie)
X-Cache-Status header absent or emptyCache not used at allLocation, method or key length: see the last section

How the cases were reproduced#

Everything below was observed on 22 October 2026 unless it is attributed to documentation or source code. nginx 1.24.0, 1.27.5, 1.28.3 and 1.30.6 ran from the official Alpine images with one configuration and gave identical results for every case. The backend was a Python 3.13 server that returned a chosen set of headers on each path and counted the requests it received, so a HIT could be confirmed by the count not moving. Each case was requested three times with curl 7.68.0, and the status was read from add_header X-Cache-Status $upstream_cache_status always; and from the access log. The cache zone was proxy_cache_path ... keys_zone=c:1m inactive=60m use_temp_path=off, with the default proxy_cache_key. PHP 8.4.26, Express 5.3.0 with express-session 1.19.0 on Node 24.22.0, and Django 6.1.2 were run to record the headers they send by default. The Spring Security and Rails rows come from their documentation and source and were not run.

How nginx decides to store a response#

The proxy module documentation describes it in one list under proxy_cache_valid. Headers in the response have "higher priority than setting of caching time using the directive": X-Accel-Expires first, then Expires or Cache-Control. "If the header includes the "Set-Cookie" field, such a response will not be cached", and the same goes for Vary: *. Only when the response has no header that sets a lifetime does proxy_cache_valid supply one.

The source in ngx_http_upstream.c adds the details the documentation leaves out, and they explain most of the surprising results:

  1. Cache-Control is searched as a string, not parsed. nginx looks for the substrings no-cache, no-store and private anywhere in the value. If one is present the response is not cached and nothing else in the header is read.
  2. s-maxage= is looked for before max-age=. A value of 0 in whichever it finds means not cacheable. Any other number becomes the lifetime and overrides both Expires and proxy_cache_valid.
  3. The number must be bare digits. Anything else before the next comma, semicolon or space is a parse error, and a parse error marks the response not cacheable.
  4. An Expires that is unparseable or in the past marks the response expired, unless a Cache-Control max-age or X-Accel-Expires sets a lifetime. An expired response is not stored, whatever proxy_cache_valid says.
  5. X-Accel-Expires overrides everything except Set-Cookie. A positive value clears the "do not cache" flags that Cache-Control set; a value of 0 forbids storage.
  6. Vary must fit in 128 bytes (NGX_HTTP_CACHE_VARY_LEN in ngx_http_cache.h), counting multiple Vary lines joined with , .

Only after all of that, and after proxy_no_cache, does nginx look at proxy_cache_valid. If the response still has no lifetime and no line in proxy_cache_valid matches its status, it is not stored.

Response headers that stop caching#

These were all tested with proxy_cache_valid 10m in the location, so every MISS below happened despite a lifetime being configured.

Backend response headernginx 1.24.0 to 1.30.6Why
Set-Cookie: sid=abcMISSDocumented: never cached
Cache-Control: private, max-age=60MISSSubstring private
Cache-Control: no-cacheMISSSubstring no-cache
Cache-Control: no-storeMISSSubstring no-store
Cache-Control: max-age=0MISSLifetime 0
Cache-Control: public, s-maxage=0, max-age=600MISSs-maxage read first, and it is 0
Cache-Control: max-age="60"MISSQuoted number is a parse error
Cache-Control: no-cache="Set-Cookie", max-age=60MISSSubstring no-cache
Cache-Control: max-age=60, must-revalidateHITmust-revalidate is not checked
Cache-Control: public, max-age=60 (no proxy_cache_valid)HITThe header supplies the lifetime
Expires: Thu, 01 Jan 1970 00:00:01 GMTMISSAlready expired
Expires: 0MISSInvalid date counts as expired
Cache-Control: max-age=60 plus a past ExpiresHITmax-age wins
X-Accel-Expires: 0MISSDocumented: zero disables caching
Cache-Control: private plus X-Accel-Expires: 60HITX-Accel-Expires overrides it
Set-Cookie plus X-Accel-Expires: 60MISSNothing overrides Set-Cookie
Vary: *MISSDocumented since 1.7.7
Vary: with a 128-byte valueHITAt the limit
Vary: with a 129-byte valueMISSOver the limit
X-Accel-Buffering: noMISSTurns buffering off for this response
Pragma: no-cacheHITnginx does not read Pragma

Three rows in that table go against the way RFC 9111 reads, and that is where most of the confusion comes from.

Qualified no-cache is treated as plain no-cache. RFC 9111 section 5.2.2.4 lets a cache store a response with no-cache="Set-Cookie" and serve it without the listed field. nginx sees the substring and refuses it outright, which is the safe reading but means the header does not do what the application intended.

A quoted max-age is refused, not ignored. The RFC says a sender "MUST NOT generate the quoted-string form" of max-age, but also that "recipients ought to accept both forms". nginx accepts neither: max-age="60" made the response uncacheable even though proxy_cache_valid 10m would have cached the same response with no Cache-Control at all. Hand-written headers in application code are where this turns up.

Expires: 0 is correct behaviour. The RFC says a cache "MUST interpret invalid date formats, especially the value "0", as representing a time in the past". Spring Security sends exactly that by default, alongside a Cache-Control that would block caching anyway.

The 128-byte Vary limit is easy to reach with CORS and client hints. Accept-Encoding, Accept-Language, Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Sec-CH-UA, Sec-CH-UA-Platform is 134 bytes, and a response carrying it was never cached. When the app and nginx both add Vary lines, nginx joins them before measuring, so two reasonable lines can cross the limit together. How duplicate CORS headers arise behind a proxy is covered in why Access-Control-Allow-Origin contains multiple values.

What your framework sends by default#

The backend usually sends these headers without anyone having asked for them. The defaults below are the ones to look for when a cache that should work never does.

BackendWhat it sends by defaultEffect on nginx
PHP 8.4.26, any page that calls session_start()Set-Cookie: PHPSESSID=... on the first visit, and on every request Cache-Control: no-store, no-cache, must-revalidate, Expires: Thu, 19 Nov 1981 08:52:00 GMT, Pragma: no-cacheMISS, for visitors with and without a session cookie
PHP 8.4.26, page without a sessionNo caching headersMISS until proxy_cache_valid is set
Express 5.3.0A weak ETag, no Cache-ControlMISS until proxy_cache_valid is set
express-session 1.19.0, saveUninitialized: trueSet-Cookie: connect.sid=... on every request that arrives without the cookieMISS for every new visitor, and for every curl test
express-session 1.19.0, saveUninitialized: falseNo cookie until the session is modifiedCacheable
Django 6.1.2, a view that reads or writes request.sessionVary: Cookie, plus Set-Cookie: sessionid=... when it writesOne cache entry per distinct Cookie header
Django 6.1.2, a page with a CSRF token (the admin login)Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private, Set-Cookie: csrftoken=...MISS
Spring Security (documentation)Cache-Control: no-cache, no-store, max-age=0, must-revalidate, Pragma: no-cache, Expires: 0MISS
Rails (source)Cache-Control: max-age=0, private, must-revalidate (DEFAULT_CACHE_CONTROL in actionpack) on responses with an ETag and no caching directiveMISS

PHP's headers come from session.cache_limiter, whose default is nocache; the PHP manual lists public, private_no_expire, private and nocache as the values. Spring Security's documentation explains its choice: "Spring Security's default is to disable caching to protect the user's content", and it lets the application override the header per response.

Vary: Cookie does not produce MISS on every request, but it comes close in practice. nginx keeps a separate variant for each distinct Cookie header value: two requests with Cookie: a=1 and Cookie: a=2 each went to the backend once, and each then got its own HIT. A site with an analytics or consent cookie gives nearly every visitor a different Cookie header, so nearly every first request is a MISS, while a test run from one browser looks perfect. Vary: Accept-Encoding behaves the same way on a smaller scale: three clients sending no Accept-Encoding, gzip, and gzip, deflate, br made three backend requests for one URL. That is why a page tested with curl and then with a browser shows MISS twice. Compression through proxies covers normalising the encoding before it reaches the key.

Configuration that stops caching#

No proxy_cache_valid. With proxy_cache set and nothing else, a response with no Cache-Control and no Expires was MISS on every request, and the same response with Cache-Control: public, max-age=60 was cached without any proxy_cache_valid line. nginx has no built-in default lifetime.

proxy_cache_valid with only a time. The documentation is explicit: with proxy_cache_valid 5m; "only 200, 301, and 302 responses are cached". A 404 under proxy_cache_valid 10m stayed MISS; adding proxy_cache_valid any 1m made it a HIT. This catches API endpoints whose normal answer is a 204 or a 404.

proxy_buffering off. The cache writes the response to disk through the buffered path. With buffering off nginx releases the cache entry before it starts sending, and every request was MISS. A backend can cause the same thing for one response with X-Accel-Buffering: no, which streaming endpoints send to turn buffering off for themselves. Proxy buffering and streaming covers the trade-off; a location cannot have both streaming and a cache.

proxy_cache_min_uses. With 3, the first three requests were MISS and reached the backend, and the fourth was the first HIT. The default is 1.

proxy_cache_bypass on a cookie. A common recipe is proxy_cache_bypass $http_cookie; to skip the cache for logged-in users. Any cookie at all makes it true, so behind a site with analytics cookies every browser request was BYPASS. Bypass also does not stop storage: the bypassed responses were still written to the cache, and the next request without a cookie got a HIT on a response generated for a request that had one. Use proxy_no_cache with the same condition if those responses must not be stored. The existing caching guide covers why the two directives have to be used together.

proxy_no_cache that is always true. A request with ?nc=1 under proxy_no_cache $arg_nc stayed MISS every time, as configured. Check the variables: a header that every client sends makes the condition permanently true.

An unwritable cache directory. nginx's master creates the cache directory at startup, owned by the worker user. When it was made unwritable afterwards (a volume mount, a backup restore, a chown by a deploy script), every request was MISS and each one logged:

text
[crit] 30#30: *191 rename() "/var/cache/nginx/proxy_temp/4/03/0000000034" to "/var/cache/nginx/u/75589d17b5355968da8be90e5212b627" failed (13: Permission denied) while reading upstream

Of the causes that give MISS, this is the only one that writes to the error log at its default level. With use_temp_path=on (the default) the file is written under proxy_temp_path first and renamed into the cache, so both directories must be writable. The same temp directory failing during a download truncates the response instead, as described in why nginx cuts off large downloads.

Request details that do not matter, and one that should#

nginx ignores what the client says about caching. A request with Cache-Control: no-cache and Pragma: no-cache got a HIT. MDN notes that "browsers usually add no-cache to requests when users are force reloading a page", so a force reload reaches nginx and is answered from its cache. RFC 9111 says the request directive means "the client prefers a stored response not be used", and nginx does not act on that preference. A force reload is therefore not a way to test whether the origin changed.

A Cookie header on the request made no difference either: it is not part of the default key and nginx does not read it unless the response says Vary: Cookie or a proxy_cache_bypass refers to it.

Authorization is the one that should matter. RFC 9111 section 3.5 says a shared cache "MUST NOT use a cached response to a request with an Authorization header field to satisfy any subsequent request" unless the response's Cache-Control allows it. nginx does not check this. A request with Authorization: Bearer alice was cached, and a following request with no Authorization header got a HIT whose body the backend had generated for alice. If a cached location can receive authorised requests, add proxy_no_cache $http_authorization; and proxy_cache_bypass $http_authorization;, or put the user in proxy_cache_key. The proxy security checklist lists this with the other ways a shared cache leaks data.

Every distinct $request_uri is its own entry, so ?a=1, ?a=2 and ?utm_source=x for the same page were three MISSes and three backend requests. Campaign parameters look like a cache that is not working when it is just keying correctly. A HEAD request was converted to GET for caching (proxy_cache_convert_head is on by default), stored, and the next GET was a HIT.

When X-Cache-Status is empty, the cache did not run#

$upstream_cache_status has a value only if nginx looked up the request in a cache. With add_header ... always, an empty value means no header at all, and the access log shows -. That is a different fault from MISS, and changing response headers will not fix it.

The request was served by another location. A location ~ \.json$ with proxy_pass but no proxy_cache took /regex/a.json away from the /regex/ prefix location that had the cache, because a regex beats a prefix. The status was empty on every request. Put proxy_cache at server level or in every location that proxies. The matching order is in nginx as a reverse proxy.

The method is not cached. A POST under the cache got an empty status, and the following GET for the same URL was still a MISS: nothing was stored. proxy_cache_methods defaults to GET HEAD.

The key is longer than the buffer. The cache file header, including the key, has to fit in proxy_buffer_size with 256 bytes to spare. With the default of 4096 bytes on these builds, a 3,494-byte key was cached and a 3,495-byte key was not. Each failing request logged, at error level:

text
[error] 30#30: *180 proxy_buffer_size 4096 is not enough for cache key, it should be increased to at least 5120

Then the request went to the backend uncached. With the default key of scheme, upstream name and request URI, that means a URL around 3,480 characters long, which tracking parameters and long search queries do reach. Raise proxy_buffer_size as the message says; it is also the buffer for response headers, covered in header and body size limits.

Finding the cause in one request#

Put the status in the access log so the question is answered for every request rather than the one you tried:

nginx
log_format cache '$remote_addr "$request" $status cache=$upstream_cache_status upstream=$upstream_status';
access_log /var/log/nginx/access.log cache;
add_header X-Cache-Status $upstream_cache_status always;

Then read the backend's headers from the nginx host, using the same Host and the same Accept-Encoding a browser would send, because those decide the variant:

bash
curl -s -o /dev/null -D - -H 'Host: www.example.com' -H 'Accept-Encoding: gzip' http://127.0.0.1:8000/page

If the headers look clean and it is still MISS, the official Docker images include a debug build. Run nginx-debug with error_log ... debug; and the decision appears per request as http cacheable: 0 or 1. The line does not say which header caused it, but it tells you nginx made the decision at response time rather than skipping the cache. More on sending exactly the request you mean is in curl through a proxy.

Forcing a cache when the backend says no#

proxy_ignore_headers makes nginx disregard the headers it would otherwise obey. It works, and it is how private data ends up in a shared cache, so use it only on locations whose responses are the same for every user.

With proxy_ignore_headers Set-Cookie; alone, the response was cached and the HIT still carried Set-Cookie: sid=abc: nginx stores the response headers with the body and replays them. Every visitor then received the same session cookie. Always pair it with proxy_hide_header:

nginx
location /static-pages/ {
    proxy_cache c;
    proxy_cache_valid 200 10m;
    proxy_ignore_headers Set-Cookie Cache-Control Expires;
    proxy_hide_header Set-Cookie;
    proxy_no_cache $http_authorization;
    proxy_cache_bypass $http_authorization;
    proxy_pass http://app;
}

With proxy_hide_header Set-Cookie the cached response went out without the cookie. proxy_ignore_headers Vary also made a Vary: * response cacheable, which removes the variant split entirely: every client then gets whichever encoding or language was stored first.

The better fix is at the origin. Send Cache-Control: public, s-maxage=600 on pages that are the same for everyone, so that nginx and any CDN in front of it cache them, while browsers follow max-age instead. Do not start a session for anonymous visitors: saveUninitialized: false in express-session, no session_start() on public PHP pages, no session access in Django views that do not need it.

Failure modes#

MISS on every request, and the backend sends Set-Cookie. A session or CSRF cookie on a public page. Stop issuing it, or ignore and hide it as above.

MISS on every request, with proxy_cache_valid set. The response's own headers win. Look for private, no-cache, no-store, max-age=0, s-maxage=0, a quoted max-age or a past Expires.

MISS on every request, and the backend sends no caching headers. No proxy_cache_valid, or one that does not list the status code.

MISS for every new visitor, HIT in your own tests. Vary: Cookie with per-visitor cookies, or a cookie set on the first visit only.

MISS for a streaming or SSE location. proxy_buffering off or X-Accel-Buffering: no.

MISS everywhere after a deploy or restore, with crit lines in the error log. The cache or temp directory is not writable by the worker user.

BYPASS for every browser, HIT from curl. A proxy_cache_bypass that tests $http_cookie.

No X-Cache-Status header at all. A location without proxy_cache, a non-GET method, or a cache key over the proxy_buffer_size limit.

One user's page served to another. A cached location received Authorization or a session cookie, and nothing excluded those requests. Add proxy_no_cache and proxy_cache_bypass on the credential, and hide Set-Cookie if headers are ignored.

Where this answer stops applying#

The results are for nginx 1.24.0, 1.27.5, 1.28.3 and 1.30.6 open source builds with the configuration described, over HTTP/1.1. NGINX Plus was not tested. The framework rows are for the versions listed with their default settings; Spring Security and Rails were read from documentation and source, not run. The 4096-byte proxy_buffer_size default is one memory page on these builds and may be 8k elsewhere, which moves the key length limit. Entries that were cached and then disappeared after inactive (10 minutes by default) are a different problem, covered with the other proxy_cache_path parameters in caching in reverse proxies. For a cache in front of a backend that fails, and the 502s it does or does not hide, see the proxy debugging playbook.

Frequently asked questions#

Because a cached Set-Cookie would be replayed to every visitor. nginx refuses to store such a response by default, and nothing in the response, not even X-Accel-Expires, overrides that. proxy_ignore_headers Set-Cookie does, but then use proxy_hide_header Set-Cookie as well, or the stored cookie is sent with every HIT.

Do I need proxy_cache_valid for nginx to cache?#

Only if the backend sends no lifetime. A response with Cache-Control: max-age=60 or a future Expires was cached with no proxy_cache_valid at all. A response with neither header is never cached without it, and proxy_cache_valid 10m with no status codes covers only 200, 301 and 302.

Why is X-Cache-Status empty or missing?#

The request did not go through the cache. Either it was served by a location without proxy_cache (often a regex location), the method is not in proxy_cache_methods, or the cache key was too long for proxy_buffer_size, which logs an error line saying so.

Does a browser force reload bypass the nginx cache?#

No. Browsers usually send Cache-Control: no-cache on a force reload, and nginx ignores that header and Pragma: no-cache unless you add them to proxy_cache_bypass. The reload skips the browser's own cache, and nginx still answers with a HIT.

nginx searches the header for the text no-cache and finds it, so it treats the response as uncacheable. It does not implement the qualified form, which in RFC 9111 would allow storing the response without the named field.

Does nginx proxy_cache respect the Authorization header?#

No. A response to a request with Authorization was stored and served to a request without it. Add proxy_no_cache $http_authorization; and proxy_cache_bypass $http_authorization; to every cached location that can receive authenticated requests.

Why is the first request after changing Accept-Encoding a MISS?#

The backend sent Vary: Accept-Encoding, so nginx keeps a separate copy for each distinct Accept-Encoding value. curl without the header and a browser sending gzip, deflate, br hit different entries.

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. nginx ngx_http_proxy_module (proxy_cache, proxy_cache_valid, proxy_ignore_headers, proxy_cache_bypass, proxy_no_cache, proxy_cache_path, proxy_buffering)
  2. nginx source, ngx_http_upstream.c (Cache-Control, Expires, X-Accel-Expires and Vary processing)
  3. nginx source, ngx_http_cache.h (NGX_HTTP_CACHE_VARY_LEN)
  4. RFC 9111, HTTP Caching
  5. PHP manual, session configuration (session.cache_limiter)
  6. Spring Security reference, default security HTTP response headers
  7. Rails source, actionpack http/cache.rb (DEFAULT_CACHE_CONTROL)
  8. express-session README (saveUninitialized)
  9. Django source, contrib/sessions/middleware.py (patch_vary_headers Cookie)
  10. MDN, Cache-Control request directives (no-cache on force reload)

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 troubleshooting proxies#