Nginx proxy_cache can make a struggling application look healthy again, but it can also serve the wrong page to the wrong user if you get sloppy. I have seen both outcomes. The good version is a backend that suddenly stops melting under traffic spikes. The bad version is an authenticated dashboard cached for everyone because somebody ignored cookies and shipped it anyway.
This is the practical guide I wish more teams followed before enabling response caching in front of an application. If you are building the proxy layer from scratch, start with my reverse proxy SSL guide. If you are trying to protect a busy endpoint, read this together with my rate limiting guide and my new load balancing article. They solve different problems, but in production they often meet.
What proxy_cache actually does
Nginx can cache responses from an upstream server and serve them directly on later requests. This reduces backend load, shortens response times, and smooths out bursts. The cache lives on disk, with metadata in shared memory.
At its simplest, the flow is:
- Client requests a cacheable resource.
- Nginx checks whether it already has a valid cached copy.
- If yes, it serves from cache.
- If no, it fetches from upstream, stores the response, and serves it.
That sounds trivial until cookies, authorization, query strings, and upstream headers get involved. So I like to define the cache explicitly instead of relying on defaults I did not review.
Defining the cache store with proxy_cache_path
The cache is created in the http block with proxy_cache_path.
http {
proxy_cache_path /var/cache/nginx/proxy_cache
levels=1:2
keys_zone=app_cache:100m
max_size=10g
inactive=60m
use_temp_path=off;
}
Here is what matters:
levels=1:2creates nested directories so you do not dump everything into one huge directory.keys_zone=app_cache:100mcreates shared memory for cache keys and metadata.max_size=10gcaps disk usage.inactive=60mremoves items not accessed for sixty minutes, even if they have longer freshness metadata.use_temp_path=offavoids writing temp files to a different filesystem before moving them.
I keep the cache under /var/cache/nginx/ unless I have a specific storage design. Make sure the filesystem has enough space and the Nginx user can write there.
Validation first:
sudo nginx -t
sudo install -d -o www-data -g www-data -m 0750 /var/cache/nginx/proxy_cache
sudo systemctl reload nginx
Use the correct service user for your distro. On RHEL-like systems that may be nginx instead of www-data.
A safe starting configuration
Once the cache path exists, I apply it carefully at the location level.
upstream app_backend {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 80;
server_name example.com;
add_header X-Cache-Status $upstream_cache_status always;
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_cache app_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 301 302 10m;
proxy_cache_valid 404 1m;
proxy_pass http://app_backend;
}
}
That gives me a visible cache status header and a deliberate key. I almost always include the request method and host in the key. If you run multiple domains through one cache zone and forget the host, you are asking for trouble.
Building a good cache key
A cache key decides whether two requests represent the same object.
This is common and usually fine:
proxy_cache_key "$scheme$request_method$host$request_uri";
I use $request_uri rather than $uri when query strings are meaningful. If the application uses query parameters for filters, paging, or language selection, collapsing them is wrong.
Sometimes I customize the key more aggressively. For example, if I know marketing tags should not create separate cache objects, I normalize them upstream in the app or at the rewrite layer rather than stuffing conditional logic into proxy_cache_key.
I do not like clever cache keys. I like obvious ones that match application behavior.
Freshness control with proxy_cache_valid
proxy_cache_valid defines how long specific response codes remain fresh when the upstream does not provide its own cache lifetime.
proxy_cache_valid 200 10m;
proxy_cache_valid 301 1h;
proxy_cache_valid 404 30s;
I usually keep 404s short. A missing object may appear shortly after deployment or content publication. Long 404 caching can make debugging frustrating.
For redirects, longer values are often fine if the redirect is deliberate and stable.
A common mistake is using one blanket duration for everything. Dynamic pages, static assets, error responses, and redirects usually deserve different treatment.
Cache-control, Pragma, and Expires
Upstream headers matter unless you explicitly ignore them.
In normal setups, Nginx respects cache-related headers from the backend, including Cache-Control, Expires, and some related behaviors. That is often the right choice. If the application already knows what is private, stale, or immediately expired, let it say so.
I pay attention to:
Cache-Control: no-cacheCache-Control: no-storeCache-Control: privatePragma: no-cacheExpires
The Pragma header still shows up from older stacks, especially when legacy middleware or frameworks are involved. It is worth checking response headers directly:
curl -I http://example.com/
curl -I http://example.com/login
If an upstream insists on disabling caching and you override it, do it because you understand the response class, not because you want a quick benchmark win.
Bypassing and preventing cache storage
There are two related controls people mix up:
proxy_cache_bypassdecides whether Nginx should skip a cache lookup.proxy_no_cachedecides whether the response should be stored.
I often use both with the same conditions.
map $http_cookie $skip_cache_cookie {
default 0;
~*session= 1;
~*wordpress_logged_in_ 1;
}
map $http_authorization $skip_cache_auth {
default 0;
~.+ 1;
}
server {
add_header X-Cache-Status $upstream_cache_status always;
location / {
proxy_cache app_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_bypass $skip_cache_cookie $skip_cache_auth;
proxy_no_cache $skip_cache_cookie $skip_cache_auth;
proxy_pass http://app_backend;
}
}
This is one of the most important safety patterns in the whole article. If a request carries a session cookie or Authorization header, I do not want a shared public cache involved unless I designed specifically for that case.
The classic authentication pitfall
The most dangerous mistake is caching authenticated content.
If a user-specific page can be reached with a session cookie and your cache rules do not bypass or suppress storage, you can leak private content. This is not theoretical. I have seen teams accidentally cache logged-in homepages, admin fragments, and API responses with customer data.
My rule is simple: if a location can ever return personalized content, I start by assuming it must not be cached. Only after I prove otherwise do I relax the rule.
That also applies to partial personalization. A page can look mostly public and still contain user state in a corner.
Debugging with X-Cache-Status
I add a response header during rollout because it cuts debugging time dramatically.
add_header X-Cache-Status $upstream_cache_status always;
You will usually see values such as:
MISSBYPASSEXPIREDSTALEUPDATINGHITREVALIDATED
Test with repeated requests:
curl -I http://example.com/
curl -I http://example.com/
curl -I -H 'Authorization: ******' http://example.com/
If the first request is a MISS and the second becomes a HIT, the basic path works. If everything is BYPASS, inspect cookies, auth headers, or your bypass maps. If everything is MISS, the response may not be cacheable or the cache path may be misconfigured.
Microcaching for dynamic pages
Microcaching is one of my favorite Nginx tricks when traffic is bursty and application responses are expensive. The idea is to cache a dynamic page for a very short time, often one to five seconds.
location /news/ {
proxy_cache app_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 2s;
proxy_cache_valid 404 1s;
proxy_cache_bypass $skip_cache_cookie $skip_cache_auth;
proxy_no_cache $skip_cache_cookie $skip_cache_auth;
proxy_pass http://app_backend;
}
That sounds tiny, but under heavy concurrent load it can absorb a huge amount of duplicate work. I have used two-second caches to flatten traffic spikes during product launches and busy login-adjacent landing pages.
The trade-off is freshness. If you need exact per-request updates, microcaching is the wrong tool. But for pages where a one- or two-second lag is acceptable, it is surprisingly effective.
Preventing cache stampedes with proxy_cache_lock
One thing that bites people on busy sites is the cache stampede problem. A hot object expires, hundreds of clients request it at once, and suddenly all of them rush upstream because the cache entry is being refreshed. Microcaching helps, but I still like explicit locking when the endpoint is expensive.
location /api/public/ {
proxy_cache app_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_lock on;
proxy_cache_lock_timeout 10s;
proxy_cache_lock_age 5s;
proxy_cache_valid 200 30s;
proxy_pass http://app_backend;
}
With proxy_cache_lock on, the first request refreshes the object while later requests wait for the updated cache entry instead of all hammering the backend. I do not turn it on everywhere without thought, because waiting requests still consume client patience, but for expensive shared objects it is usually a net win.
When I test this, I hit the same URL concurrently and watch both the response headers and upstream load. If every request becomes a cache miss after expiry, locking is either disabled or the object is not actually cacheable.
Why caching POST requests is usually wrong
People ask about caching POST responses because some APIs are expensive. Technically, Nginx can be configured to cache methods beyond GET and HEAD:
proxy_cache_methods GET HEAD POST;
I rarely do this. POST usually implies state change, sensitive input, or at least semantics that other people on the team will not expect to be cached. If a POST endpoint is read-like and expensive, I would rather redesign it as a GET with clear cache behavior or solve it inside the application.
If you insist on caching POST, be brutally explicit about the cache key, request body implications, authorization, and invalidation model. For most environments, the safer advice is simple: do not.
Serving stale content while the backend catches up
One of the best production patterns is serving stale content when the upstream is slow or briefly broken.
location / {
proxy_cache app_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 10m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_pass http://app_backend;
}
This combination matters:
proxy_cache_use_staleallows older cached content to be served in specific failure cases.proxy_cache_background_update onlets one request refresh the cache while others keep getting the old copy.
That is close to a practical stale-while-revalidate model, even if your upstream app does not implement it elegantly.
I like this especially for news pages, product listings, documentation, and landing pages. I do not use it for account dashboards or fast-changing private data.
Ignoring upstream cache headers when you truly mean it
Sometimes the upstream sends cache-disabling headers that are too conservative for a specific public location. Then you can ignore them.
location /public/ {
proxy_cache app_cache;
proxy_ignore_headers Cache-Control Expires Set-Cookie;
proxy_cache_valid 200 5m;
proxy_pass http://app_backend;
}
This is powerful and dangerous. Set-Cookie in particular is not a header I ignore casually. If the upstream sets cookies for session state, experiments, CSRF handling, or consent, your cache behavior can drift far away from application intent.
When I use proxy_ignore_headers, I document exactly why, and I validate actual responses with curl -I before and after the change.
Where cache files live and how I inspect them
Cache files are stored under the path from proxy_cache_path, using hashed directory levels.
Inspect the directory:
sudo find /var/cache/nginx/proxy_cache -maxdepth 3 -type f | head
sudo du -sh /var/cache/nginx/proxy_cache
That does not tell you much about which URL maps to which file, but it is enough to confirm the cache is active and growing. If the directory stays empty during repeated public requests, either nothing is cacheable or writes are failing.
I also check permissions and filesystem usage:
sudo ls -ld /var/cache/nginx/proxy_cache
sudo df -h /var/cache/nginx/proxy_cache
Do not wait for a disk-full event to learn that your cache partition sizing was optimistic.
Purging cached content
Open source Nginx does not give you the same integrated purge workflow that some people expect from commercial builds or third-party modules.
If you have NGINX Plus or an environment with a supported purge module, you can build an API-driven purge process. Otherwise, I usually choose one of these approaches:
Short TTL plus versioned URLs
This is my preferred pattern for many sites. Keep cache lifetimes reasonable and version static or semi-static URLs when content changes.
Manual cache cleanup
If I have to invalidate aggressively and cannot change URLs, I may remove cache files during a maintenance window and reload Nginx:
sudo systemctl stop nginx
sudo rm -rf /var/cache/nginx/proxy_cache/*
sudo systemctl start nginx
That is blunt, but it is valid and predictable.
Rotate to a new cache zone or path during planned changes
I have done this in larger deployments to avoid partial invalidation complexity.
What I do not do is promise surgical purge semantics unless the tooling actually supports it.
Troubleshooting a cache that is not helping
When proxy_cache seems ineffective, I walk through the same checklist.
Validate config and reload
sudo nginx -t
sudo systemctl reload nginx
Check response headers
curl -I http://example.com/
Look for Set-Cookie, Cache-Control, Pragma, Expires, and your X-Cache-Status header.
Review logs
Pair this with stronger logging. My Nginx access and error log analysis guide goes deeper, but even a basic access log can show whether the same object is being requested repeatedly and whether upstream times improved after rollout.
Watch for method and query-string variation
I have seen cache miss storms caused by useless query parameters added by ads, tracking links, and frontend cache-busting hacks.
Make sure you are not solving the wrong bottleneck
If the upstream is timing out, connection-starved, or badly balanced, caching helps only so much. That is where my 502 guide and 503 troubleshooting article become relevant.
Security considerations
A shared reverse proxy cache is a trust boundary. Treat it like one.
I pay close attention to:
- authenticated pages
- session cookies
Authorizationheaders- CSRF-protected forms
- per-user API data
- sensitive response headers
If the content is private, assume it must bypass cache unless you have a very deliberate design. Also remember that stale content can become a policy problem if users expect immediate revocation or visibility changes.
Final thoughts
Nginx proxy_cache is one of the most useful performance tools in the stack, but only when the cache key, bypass rules, and response classes are chosen carefully. I start small, expose X-Cache-Status, verify real headers with curl, and refuse to cache anything even slightly personalized until I have proved it is safe.
That slower rollout saves time later. The best cache deployment is not the one with the biggest hit rate. It is the one that cuts backend work without serving the wrong content to the wrong person.