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.

DirectiveWhat it decidesExample value
proxy_cache_pathWhere bodies are stored, how the directory splits, how much memory the zone gets, when an unused entry is droppedlevels=1:2 keys_zone=custom_cache:10m inactive=60m
proxy_cache_keyWhat makes separate requests share one entry$scheme$request_method$host$request_uri
proxy_cacheWhich zone a location reads from and writes tocustom_cache
proxy_cache_validHow long a response is reused when the origin gives no lifetime200 30s
proxy_cache_bypassWhich 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.

Nginx response headers for the same URL showing X-Cache-Status MISS then HIT with the origin request counter unchanged, then BYPASS for a request carrying Cache-Control no-cache
The first request stores the response, the second is served from it, and a request carrying Cache-Control: no-cache skips the stored copy.

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.

Nginx response headers showing one URL returning X-Cache-Status MISS then HIT for a plain curl, and MISS again for a gzip-capable curl
The same URL stored under separate entries, because the clients declare different encodings.

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.

StatusWhat Nginx didFirst thing to check
MISSNothing was stored for that keyThe origin’s response headers and any Set-Cookie
HITThe stored copy was usedNothing, this is the working state
BYPASSThe request skipped the stored copyThe proxy_cache_bypass conditions
EXPIREDAn entry existed and its lifetime had run outproxy_cache_valid and the origin’s max-age
STALEAn expired entry was served during a refreshproxy_cache_use_stale and the stale window
MISS on every requestThe response was never storableSet-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.

Pankaj Kumar
Pankaj Kumar

Pankaj Kumar is the founder and CEO of CodeForGeek, with more than 14 years in IT. He is an open-source enthusiast who enjoys sharing what he learns through CodeForGeek and YouTube, with a focus on Python, data analytics, machine learning, Angular, Node.js, and Kafka.

Articles: 335