New to Rust? Grab our free Rust for Beginners eBook Get it free →
A Guide to Caching with Nginx

My first cached request came back with a MISS in the status header, and nothing in the configuration or the error log said whether the cache was working or ignoring that response.
So I built the setup again on Nginx 1.24 in front of a small Node origin and ran every cache state until the header explained itself. The status value, the cache key and the origin’s headers separate MISS, HIT, BYPASS and the responses Nginx refuses to store.
What Nginx caches in front of your app
Nginx already avoids your application for anything it can serve from disk, and that covers a location mapped to a directory through root or alias. The file comes straight from the filesystem, and open_file_cache keeps the descriptors and metadata in memory so repeated lookups stay cheap.
A proxy cache covers the case where only your application can build the response. The first request for a URL goes upstream, Nginx stores the response under a cache key, and the next request for that key is answered from the stored copy without waking the application at all.
The state lives in two places. Shared memory holds the keys and the metadata, while the disk holds the response bodies, so clearing the zone in memory loses the index rather than the stored pages.
| Directive | What it decides | Example value |
|---|---|---|
| proxy_cache_path | Where bodies are stored, how the directory splits, how much memory the zone gets, when an unused entry is dropped | levels=1:2 keys_zone=custom_cache:10m inactive=60m |
| proxy_cache_key | What makes separate requests share one entry | $scheme$request_method$host$request_uri |
| proxy_cache | Which zone a location reads from and writes to | custom_cache |
| proxy_cache_valid | How long a response is reused when the origin gives no lifetime | 200 30s |
| proxy_cache_bypass | Which requests skip the stored copy | $http_cache_control |
The directive list is short on purpose, because the behaviour that costs people time is not in the configuration. It is in the string Nginx uses as the key and in the response headers your application sends back.
What you need before the first request
You need Nginx installed and serving, permission to reload it, and an application listening on a port Nginx can reach. On Ubuntu the package comes from the distribution repository, and the version line tells you which of the directives below the build supports.
sudo apt-get update
sudo apt-get install -y nginx
nginx -v
The cache directory has to be writable by the user the worker processes run as. Nginx also writes temporary files for proxied bodies, and those paths are compiled into the binary rather than derived from the cache path you chose.
A permission failure there is quiet and misleading, because the worker cannot write to the compiled-in temporary directory when Nginx runs as a normal user and the client sees an empty reply with no status code in it at all.
client_body_temp_path /var/cache/nginx/temp_client;
proxy_temp_path /var/cache/nginx/temp_proxy;
fastcgi_temp_path /var/cache/nginx/temp_fastcgi;
uwsgi_temp_path /var/cache/nginx/temp_uwsgi;
scgi_temp_path /var/cache/nginx/temp_scgi;
Before you cache anything, settle one decision, because it shapes the rest of the configuration: which responses may be shared between visitors. A stored page that carries a name, a cart or a session token will be handed to the wrong person, so the bypass rules below are not optional extras.
- Nginx installed, running, and reloadable.
- An origin on a port Nginx can reach, with a way to count how many requests it received.
- A cache directory and a temporary-file directory the worker user can write.
- A written answer to which responses are safe to share.
Turn the cache on and read the first HIT
The order matters, because each step gives you something to compare against. Start with the origin alone, add the zone, then add the cache to one location and watch a single URL change state.
Define the cache zone
proxy_cache_path belongs in the http block, outside any server, because the zone is shared by every server that reads from it and has to stay visible to all of them. The levels parameter splits the directory two characters at a time.
The zone keeps keys in memory rather than on disk, so its size decides how many entries Nginx can track at once. Budget more memory for a busy site than the ten megabytes I used here, because the count grows with the number of distinct keys.
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=custom_cache:10m inactive=60m max_size=64m;
Add the cache to the location block
The location block decides which requests are eligible and how each response is labelled. Read it in the order you would say it out loud: where the cache lives, how long a response stays valid, which requests skip it, what header you add, and where the request is forwarded.
upstream my_http_servers {
server 127.0.0.1:3100;
}
server {
listen 8080;
server_name localhost;
location / {
proxy_cache custom_cache;
proxy_cache_valid 200 30s;
proxy_cache_bypass $http_cache_control;
add_header X-Cache-Status $upstream_cache_status;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://my_http_servers;
}
}
A reload starts new worker processes with the new configuration and lets the old ones finish the requests they are already serving, so nothing in flight is dropped. Restarting is the blunt version of the same change.
nginx -t
nginx -s reload
Read the status header
Nginx records the decision it made for every request in the upstream_cache_status variable. Turning that variable into a response header is what makes the cache observable from a terminal.
add_header X-Cache-Status $upstream_cache_status;
The variable carries a fixed set of values. MISS means nothing was stored for that key, HIT means the stored copy was used, and BYPASS means the request skipped the stored copy on purpose.
An entry that existed and whose lifetime had run out reports EXPIRED, while STALE means an expired entry was served during a background refresh. UPDATING and REVALIDATED cover the background-refresh and conditional-request cases.
The second request is the one that proves it
The first request for a URL always misses, because there is nothing stored yet. The second request for the same URL is the one that tells you whether the cache does anything.
I ran both against a small Node origin on port 3100 and watched the origin’s own counter. It stayed at one across the pair, so the request that reported a HIT never reached the application.

If the second request reports a MISS as well, the configuration is not the thing to change next. The response was never stored, and the section below names the reasons that happens.
Pick a cache key that matches what makes responses different
The default key is the scheme, the proxy host and the request URI, which I confirmed against the module documentation before writing the configuration. Query arguments are part of the URI, so those separate on their own, but cookies, request headers and the request method are not in the key at all.
That default is why one client reaches a HIT and another never does, because a signed-in visitor and an anonymous one share a key, and one visitor’s copy gets served to the next.
Set the key explicitly when the URL alone is not the identity of the response. Anything you can express as a variable works, including a cookie, an authorization header, or a version string you control and can change later.
proxy_cache_key "$scheme$request_method$host$request_uri$cache_version";
Why a correct configuration still returns MISS
The status header tells you what Nginx decided, so the cause is usually sitting in the same response you are already looking at. These are the cases I reproduced on a live server, and each one needs a different change.
The origin sets a cookie
A response carrying Set-Cookie is not stored, because a cookie usually means the body belongs to one person. The status stays MISS on every request, the application runs every time, and nothing is written to the error log.
Send the personalised parts through a location that bypasses the cache and keep cookies out of the responses you do want stored. Ignoring that header across the whole server is the wrong fix.
The origin says private or no-store
The values private, no-store and no-cache in Cache-Control all prevent storage, and so does an Expires header in the past. I checked both against the origin and the status never left MISS, because Nginx obeys the origin over the configuration you wrote.
proxy_ignore_headers removes a specific header from that decision when you know the response is safe to share. Keep the exception narrow, because that header was put there by somebody who had a reason.
The client sends Cache-Control: no-cache
Browsers send that header when the reader reloads a page or opens the network panel with caching disabled. With proxy_cache_bypass reading the request’s cache control header, the request skips the stored copy and comes back with BYPASS instead of HIT.
A single request header explains a whole afternoon of debugging, because the stored entry is intact and the request simply did not use it.
A gzip-capable client gets its own entry
When the origin sends Vary: Accept-Encoding, a plain request and a request that accepts gzip are different responses. Nginx stores them under separate entries, so the terminal test reports a HIT while the browser reports a MISS on the same URL.
I watched this happen with plain curl against curl –compressed, and the browser case is that request with one more header attached. Either put the header in the cache key, or enable gzip in Nginx so both clients receive the same stored body.

A stale response that never expires
proxy_cache_use_stale with the updating flag serves the old copy while Nginx refreshes it in the background. When that background request returns something Nginx cannot store, the old entry stays and keeps being served.
Nginx’s own bug tracker treats this as intended behaviour and points to stale-while-revalidate in the origin’s Cache-Control as the way to bound it. Without a bound, a page the origin deleted can still be served from the cache long after that URL returns a 404.
| Status | What Nginx did | First thing to check |
|---|---|---|
| MISS | Nothing was stored for that key | The origin’s response headers and any Set-Cookie |
| HIT | The stored copy was used | Nothing, this is the working state |
| BYPASS | The request skipped the stored copy | The proxy_cache_bypass conditions |
| EXPIRED | An entry existed and its lifetime had run out | proxy_cache_valid and the origin’s max-age |
| STALE | An expired entry was served during a refresh | proxy_cache_use_stale and the stale window |
| MISS on every request | The response was never storable | Set-Cookie, private, no-store, or Expires in the past |
If your setup already spreads traffic over several application servers, the same key decides whether the cache sits in front of the group or in front of one member, which is the part the Nginx load-balancing walkthrough builds on.
Invalidate by changing the key, not by deleting the cache
Reloading Nginx never touches a stored entry. The configuration changes, the workers restart, and every URL in the zone keeps serving the copy it already had, which is why a deploy can look like it did nothing at all.
Deleting the cache directory works and it is the wrong habit, because you throw away every entry to fix one and the next visitor pays for all of them. The alternative is to make the key carry a version you can change.
I warmed an entry with the key set to v1 and confirmed the HIT, then changed the version to v2 and reloaded. The same URL missed once on the new key and hit again on the request after that, while both old files were still sitting on disk and no longer reachable.
sed -i 's/set $cache_version "v1";/set $cache_version "v2";/' /etc/nginx/conf.d/app.conf
nginx -t
nginx -s reload
The old files stay until the inactive window passes, and the cache manager removes them on its own. Nothing serves them, because nothing addresses them any more, and the disk cost of the change is one extra copy until then.
This is also why the version belongs in the key rather than in the URL. Readers keep the address they bookmarked, and you still control which stored copy is live. Serving static assets is the other half of the same setup, and the static-file configuration covers the part Nginx answers without an upstream.
If you want to see the proxy without a cache first, the reverse proxy setup is the same location block with the cache directives removed.
Then run one command on the live site: curl for the same URL twice and read the status header both times. A MISS followed by a HIT means the cache is doing the work, and anything else points at one of the causes above.
FAQ
Does Nginx cache POST requests by default?
No. Only GET and HEAD responses are stored, and the request method is part of the default cache key, so a POST is forwarded upstream every time. Caching a POST response needs an explicit key that includes the request body, which is usually the wrong trade unless the body is a search payload you can hash.
Can Nginx cache responses for logged-in users?
Yes, with a cache key that includes the session. Add a cookie or an authorization header to proxy_cache_key so each visitor addresses a separate entry, and set proxy_no_cache when the application sends a response that must not be reused. Sharing one entry across signed-in visitors is how one person sees another person’s page.
What is the default cache key in Nginx?
The scheme, the proxy host and the request URI. Cookies, authorization headers and the Accept-Encoding header are not part of it, so two requests with different headers can land on the same entry. Set proxy_cache_key explicitly when the URL alone does not identify the response.
How do I see cache statistics in Nginx?
Read the upstream_cache_status variable, either as a response header with add_header or in the log format with log_format combined with $upstream_cache_status. Count the values over a day to see the hit ratio. Nginx does not expose totals per zone in the open-source build, so the log is the measurement.
Can I purge one URL from an Nginx cache?
Open-source Nginx has no purge directive and no API for it, and the documented purge configuration belongs to Nginx Plus. The portable options are a cache key that carries a version you can change, deleting the specific file under the cache path and letting the loader drop the entry, or installing a third-party module that adds a purge handler.




