Caching at the Edge of Your App: A Practical Look at NGINX proxy_cache
NGINX can absorb most of your read traffic before it reaches the app tier. A practical guide to proxy_cache: a working configuration, stampede protection, verification, and the staleness trade-offs.
17 Feb 2026, 15:54 UTC

Your application servers are melting under traffic that is 95% identical responses. Before you scale out the app tier or reach for a dedicated cache layer, consider that the NGINX instance already sitting in front of your app can absorb most of that load with a handful of directives. The proxy_cache feature turns NGINX into a caching reverse proxy: it stores upstream responses on disk and serves repeat requests without touching the backend at all.
The thesis of this post is simple: for read-heavy, mostly-anonymous traffic, proxy_cache is often the highest-leverage performance change you can make — but only if you design for correctness (what may be cached) before you tune for hit rate.
The two halves of a working cache
NGINX caching has a deliberate split. In the http block, proxy_cache_path defines where cached responses live: a directory on disk, a shared memory zone for keys, and an inactive lifetime after which untouched entries are evicted. Inside a location, proxy_cache switches caching on and proxy_cache_valid sets TTLs per status code. Nothing is cached until both halves exist.
The cache key matters more than people expect. By default it is derived from the scheme, host, and request URI. If your responses vary by cookie, language header, or anything else, you must say so explicitly in proxy_cache_key — or exclude those requests entirely. Serving one user's personalized page to another user is the classic way a cache rollout goes wrong.
A worked example
Here is a configuration for a typical case: a public API or content site where anonymous GET responses are safe to cache for a short window. This goes in your NGINX configuration (edit as root or via sudo, typically under /etc/nginx/), and assumes a standard NGINX build from the official packages — the proxy module is compiled in by default, which you can confirm with nginx -V and looking for the proxy-related modules.
http {
proxy_cache_path /var/cache/nginx/app
levels=1:2
keys_zone=appcache:10m
max_size=1g
inactive=30m;
server {
listen 80;
location /api/ {
proxy_pass http://app_backend;
proxy_cache appcache;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_valid 200 302 2m;
proxy_cache_valid 404 30s;
# Never cache or serve cached responses for logged-in users
proxy_no_cache $cookie_sessionid;
proxy_cache_bypass $cookie_sessionid;
# Collapse concurrent misses into one upstream request
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
# Serve stale content if the backend is down
proxy_cache_use_stale error timeout http_500 http_502 http_503;
add_header X-Proxy-Cache $upstream_cache_status always;
}
}
}A few decisions in that block deserve comment. The two-minute TTL for 200s is intentionally short: it caps staleness while still absorbing traffic spikes. The proxy_no_cache/proxy_cache_bypass pair keeps authenticated requests out of the cache entirely — no_cache prevents storing, bypass prevents reading. And proxy_cache_use_stale means a backend outage degrades into slightly-old content instead of errors, which is often the difference between an incident and a non-event.
Validate before reloading: run nginx -t as root, then nginx -s reload. Reloading is graceful, but a bad proxy_cache_path (e.g., a directory NGINX cannot write to) will fail at test or startup time, so check that the cache directory exists and is writable by the NGINX worker user.
Verifying it actually works
The X-Proxy-Cache header is your ground truth. From any client, run:
curl -I https://example.com/api/items
curl -I https://example.com/api/itemsThe first response should show X-Proxy-Cache: MISS and the second HIT. If you see BYPASS on every request, your bypass rules match more than you think; if you see no header at all, the location is not the one serving the request. To prove the stampede protection, fire a burst of concurrent first-time requests at a fresh URL (a simple shell loop with & works) and count matching lines in the upstream access log — with proxy_cache_lock on, you should see exactly one backend fetch.
The trade-offs you are accepting
Staleness is a dial, not a bug. Open-source NGINX has no built-in purge API; proxy_cache_purge requires a third-party module or a commercial build. If content must change instantly, your options are short TTLs, manually deleting files from the cache directory, or a different invalidation architecture. Design around this before launch, not after the first "the site shows old data" report.
Disk and memory need sizing. The keys_zone memory holds cache keys (roughly, 1 MB holds on the order of thousands of keys), and max_size caps disk. A zone that is too small evicts hot entries early; inactive that is too long fills the filesystem with dead entries. Watch the cache directory's disk usage during the first week.
Correctness beats hit rate. Any response that depends on per-user state must be excluded via bypass rules or a key that includes the varying input. When in doubt, bypass — a missed cache opportunity costs milliseconds; a leaked personalized response costs trust.
Where to start
Pick one read-heavy, anonymous endpoint, add the configuration above with a conservative TTL, and verify with the X-Proxy-Cache header. Measure upstream request volume before and after. If the hit ratio justifies it, expand location by location — treating each new cached route as a small correctness review, not just a config paste.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.