At some point, almost every backend hits the same wall: the database or application logic behind a popular endpoint just can’t keep churning out identical responses fast enough for the traffic hitting it. Before reaching for a dedicated caching layer like Varnish or a CDN, it’s worth knowing that Nginx already has a genuinely capable HTTP caching engine built in. I’ve used Nginx’s proxy_cache to take real production load off backends more times than I can count, and it’s often the single highest-leverage change you can make to a slow site with mostly-repeated traffic patterns.
This guide covers setting up Nginx as a caching reverse proxy in front of an existing backend — could be a Node app, a WordPress site, an API, anything speaking HTTP.
How Nginx Caching Works, Conceptually
When proxy_cache is enabled, Nginx sits between the client and your backend. On the first request for a given URL, Nginx forwards it to the backend, gets the response, stores a copy on disk (or in a configured cache zone), and serves that copy to the client. On subsequent matching requests, Nginx serves straight from its local cache — never touching the backend at all — until the cached entry expires or is explicitly purged.
This is different from browser caching (which happens on the client) and different from application-level caching (like Redis-based caching inside your app code) — it’s a caching layer that sits entirely at the proxy, transparent to both the client and the backend.
Requirements
- Nginx installed and already configured as a reverse proxy to some backend (see the Ruby/Flask/Node articles in this series for backend-specific setup)
- Enough disk space allocated for the cache (SSD strongly preferred over spinning disk for cache performance)
- A backend that sends reasonable caching-related headers (
Cache-Control,Expires) — though Nginx can override these if the backend doesn’t cooperate
Step 1: Define a Cache Zone
Cache configuration starts at the http block level, outside any server block:
http {
proxy_cache_path /var/cache/nginx/proxy_cache
levels=1:2
keys_zone=main_cache:10m
max_size=1g
inactive=60m
use_temp_path=off;
# ... rest of your http block
}
Breaking down each part of proxy_cache_path:
/var/cache/nginx/proxy_cache— the directory on disk where cached response bodies are actually stored.levels=1:2— organizes cached files into a two-level subdirectory hierarchy based on a hash of the cache key. This avoids having tens of thousands of files dumped into one flat directory, which gets slow on most filesystems.keys_zone=main_cache:10m— allocates a named shared memory zone (main_cache) for storing cache keys and metadata (not the response bodies themselves — those live on disk). 10MB of metadata is enough to track roughly 80,000 cached entries, since each entry’s metadata takes about 128 bytes.max_size=1g— caps the total disk space the cache is allowed to consume. Once this limit is hit, Nginx evicts the least recently used entries.inactive=60m— an entry is removed if it hasn’t been requested (not just aged past its freshness) in 60 minutes, regardless of whether it’s technically still “fresh” by cache-control standards.use_temp_path=off— writes cache files directly to the final cache directory instead of a separate temp path before moving them, which is both simpler and faster on modern filesystems.
Create the cache directory with correct ownership:
sudo mkdir -p /var/cache/nginx/proxy_cache
sudo chown -R www-data:www-data /var/cache/nginx
Step 2: Enable Caching in Your Server Block
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache main_cache;
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_key "$scheme$request_method$host$request_uri";
add_header X-Cache-Status $upstream_cache_status;
}
}
Key directives explained:
proxy_cache main_cache;— activates caching using the zone defined earlier.proxy_cache_valid— sets how long to cache responses by status code. Here, successful (200) and redirect (302) responses cache for 10 minutes; 404s cache briefly too (1 minute) — caching “not found” responses, even briefly, protects your backend from repeated requests hammering a broken or missing URL.proxy_cache_key— defines exactly what makes two requests “the same” for caching purposes. The default already includes scheme, method, host, and URI — I’m making it explicit here since customizing this (for example, adding a cookie or header into the key) is one of the most common real-world tweaks.add_header X-Cache-Status $upstream_cache_status;— this is invaluable for debugging. It adds a response header showing whether a given request was a cacheHIT,MISS,EXPIRED,BYPASS,STALE, orUPDATING.
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
Step 3: Verify Cache Behavior
curl -I https://example.com/some-page
Look at the X-Cache-Status header. The first request should show MISS (nothing cached yet). Run the same request again immediately:
curl -I https://example.com/some-page
This time you should see HIT — Nginx served it from cache without touching the backend at all.
Step 4: Handle Dynamic and Sensitive Content Correctly
Not everything should be cached, and getting this wrong is far more dangerous than getting it merely suboptimal — caching one user’s personalized or authenticated response and serving it to a different user is a real security and privacy bug I’ve seen happen in production.
Skip caching for anything involving cookies, authentication, or user-specific data:
location / {
proxy_pass http://backend_upstream;
proxy_cache main_cache;
proxy_cache_valid 200 10m;
# Don't cache if there's a session cookie present in the request
proxy_cache_bypass $cookie_sessionid;
proxy_no_cache $cookie_sessionid;
# Never cache POST requests, admin areas, or the cart/checkout flow
proxy_cache_methods GET HEAD;
}
location /admin/ {
proxy_pass http://backend_upstream;
# No caching directives here at all - simply omitted from this block
}
location /cart {
proxy_pass http://backend_upstream;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
proxy_cache_bypass— if this variable evaluates to non-empty/non-zero, Nginx skips reading from cache for this request (still may write to cache, though, depending onproxy_no_cache).proxy_no_cache— if this evaluates to non-empty/non-zero, Nginx won’t store this response in the cache at all.- Setting both to the same condition (like the presence of a session cookie) is the standard pattern for “logged-in users always get a fresh, uncached response.”
Step 5: Set Up Cache Purging (Optional but Useful)
Sometimes content changes and you can’t wait for the cache to expire naturally — a CMS publish event, for example. Nginx’s open-source version doesn’t include purge-by-URL out of the box, but there’s a common workaround using a special location block:
location ~ /purge(/.*) {
allow 127.0.0.1;
deny all;
proxy_cache_purge main_cache "$scheme$request_method$host$1";
}
Note: proxy_cache_purge is actually part of Nginx Plus (the commercial version) natively, or available via a third-party module (ngx_cache_purge) on open-source Nginx. If you’re on open-source Nginx without that module, the simplest reliable purge method is just deleting the specific cached file from disk directly, or clearing the whole cache directory:
sudo rm -rf /var/cache/nginx/proxy_cache/*
sudo systemctl reload nginx
This is blunt (clears everything, not one URL), but it’s dependable and requires no extra modules.
Testing Your Setup Thoroughly
Beyond the basic HIT/MISS check, test these scenarios explicitly:
# Confirm logged-in-like requests bypass cache
curl -I -H "Cookie: sessionid=abc123" https://example.com/dashboard
# Confirm cache expires correctly after proxy_cache_valid duration
curl -I https://example.com/some-page
sleep 605 # just past a 10-minute cache_valid
curl -I https://example.com/some-page # should show MISS or EXPIRED again
Also load test to see the actual backend load reduction:
# Before enabling cache, note backend request rate
# After enabling cache, compare - most repeat traffic should never reach the backend
Troubleshooting Common Issues
Cache never shows HIT — Check the Cache-Control and Set-Cookie headers your backend is sending; Nginx respects Cache-Control: no-cache, private, or the presence of Set-Cookie by default unless you explicitly override with proxy_ignore_headers:
proxy_ignore_headers Cache-Control Set-Cookie;
proxy_cache_valid 200 10m;
Use this override carefully — only when you’re certain the backend’s headers are wrong or overly conservative for your actual use case, not as a default habit.
Stale content served after a known change — Your inactive or proxy_cache_valid window is longer than expected, or a purge mechanism isn’t wired up. Consider shorter cache windows for frequently changing content, or implement the purge location block above.
Different users seeing each other’s data — This is the serious one. It means something user-specific (a cart, a dashboard, personalized content) got cached and served to a different user. Immediately add proxy_no_cache/proxy_cache_bypass rules for the affected paths and clear the cache directory. Audit every location block that touches authenticated or personalized routes.
Disk fills up — Check max_size is actually set and reasonable relative to your disk. Nginx’s eviction happens on a schedule, not instantly, so a sudden traffic spike with lots of unique URLs can temporarily overshoot before cleanup catches up — leave real headroom, don’t set max_size right up against your available disk space.
Cache warms slowly after a restart — Expected behavior; Nginx’s cache is disk-based and does persist across reloads (nginx -s reload), but a full restart or moving to a new server starts cold. Consider a cache-warming script that pre-fetches your most popular URLs after deployment if this matters for your traffic pattern.
Security Considerations
- Never cache authenticated or personalized responses without very deliberate, explicit key differentiation (and even then, think hard about whether it’s worth the risk).
- Restrict purge endpoints to internal IPs only, as shown with
allow 127.0.0.1; deny all;— an exposed purge endpoint is a denial-of-service vector (repeatedly purging forces every request back to the backend). - Set
X-Cache-Statusvisibility carefully — it’s great for your own debugging, but consider stripping it in production if you don’t want to reveal caching implementation details to external users; a simplemore_clear_headers X-Cache-Status;(viaheaders-moremodule) or just removing theadd_headerline before final deployment handles this. - Validate
proxy_cache_keydoesn’t accidentally ignore something important, like a query string parameter that actually changes the response — under-keying (too little distinguishing info) causes wrong-content bugs, while over-keying (too much) tanks your hit rate.
Performance Tips
- Use
proxy_cache_use_staleto serve slightly stale content instead of an error when the backend is slow or down — a major resilience win:
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
- Enable
proxy_cache_lockto prevent a “thundering herd” where many simultaneous requests for the same uncached URL all hit the backend at once; instead, one request populates the cache while others wait briefly:
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
- Split cache zones by content type if you have wildly different caching needs across your site (e.g., a short-lived zone for API responses, a long-lived zone for static-ish content), rather than forcing one
proxy_cache_validpolicy to fit everything. - Monitor hit ratio, not just raw traffic — a low hit ratio despite caching being “enabled” usually points at an overly specific cache key or overly short
proxy_cache_validwindow. - Place the cache on fast storage. For high-traffic sites, an NVMe-backed cache directory meaningfully outperforms spinning disk, and even outperforms network-attached storage in most cases.
Real-World Use Cases
- A WordPress blog getting hammered by a traffic spike from a viral post — enabling
proxy_cachein front of PHP-FPM cut database load by well over 90% for that period, since the vast majority of requests were identical GETs for the same post. - A public JSON API with expensive database aggregation queries behind certain read-only endpoints — caching those specific responses for a few minutes dramatically reduced database CPU usage without meaningfully harming data freshness for the use case.
- An e-commerce product catalog, where category and product pages were cached aggressively while cart, checkout, and account pages were explicitly excluded via dedicated
locationblocks — striking the balance between performance and correctness that caching always requires.
Best Practices Recap
- Define cache zones with sane
max_sizeandinactivelimits based on real disk capacity. - Always add
X-Cache-Statusduring setup and testing (and consider stripping it before final production deployment). - Explicitly exclude authenticated, personalized, and mutating (POST/PUT/DELETE) routes from caching.
- Use
proxy_cache_lockto avoid thundering-herd backend load on cache misses. - Use
proxy_cache_use_stalefor resilience during backend outages or slowness. - Build a purge strategy (module-based or manual) before you need it in an emergency, not during one.
- Watch your hit ratio over time, not just whether caching is “on.”
Done right, Nginx caching is one of the best performance-to-effort ratios available in web infrastructure — a relatively small, well-understood config addition that can take a struggling backend and make it comfortably handle traffic it previously couldn’t.