Proxy Cache #
Nginx can store backend responses to disk and serve them directly without contacting the backend again for subsequent requests. When configured correctly, this can dramatically reduce backend load — requests that would otherwise hammer the database or run heavy calculations are processed once, the result is stored, and millions of subsequent requests are served straight from cache.
But incorrectly configured caching can cause very dangerous bugs: user A gets user B’s data, or changes that should be visible immediately keep showing stale data. This article covers how it works in depth along with safe strategies.
How Proxy Cache Works #
flowchart TD
REQ["Request arrives\nGET /api/products"] --> CHK{"In cache\n& not expired?"}
CHK -- "HIT" --> SERVE["Nginx sends the response\ndirectly from cache\n(backend not contacted)"]
CHK -- "MISS" --> LOCK{"proxy_cache_lock:\nIs another request\nalready going to the backend\nfor this key?"}
LOCK -- "Yes" --> WAIT["Wait in queue\n(proxy_cache_lock_timeout)"]
WAIT --> CHK2{"Is it in\ncache now?"}
CHK2 -- "Yes" --> SERVE
CHK2 -- "No" --> BACKEND
LOCK -- "No" --> BACKEND["Nginx requests the backend\nThe backend processes & sends the response"]
BACKEND --> STORE{"Save to cache?\nCheck bypass & no_cache conditions"}
STORE -- "Yes" --> CACHE["Save to disk cache\n/var/cache/nginx/..."]
STORE -- "No (bypass)" --> NOCACHE["Send directly\nto the client, not stored"]
CACHE --> SERVE2["Send the response to the client"]Step 1: Defining a Cache Zone #
Before you can use caching in a location, you need to define a zone at the http level. This zone defines where the cache is stored and how big it is.
http {
# One zone for public content (API, pages, etc.)
proxy_cache_path /var/cache/nginx/public
levels=1:2
keys_zone=public_cache:10m
max_size=2g
inactive=1h
use_temp_path=off;
# Separate zone for API responses
proxy_cache_path /var/cache/nginx/api
levels=1:2
keys_zone=api_cache:5m
max_size=500m
inactive=10m
use_temp_path=off;
}
Parameter explanation:
| Parameter | Example | What It Means |
|---|---|---|
| Path | /var/cache/nginx/public | Directory where cache files are stored |
levels=1:2 | levels=1:2 | 2-level subdirectory structure (prevents too many files in one directory) |
keys_zone | public_cache:10m | Zone name and shared memory size for the index. 10MB ≈ 80,000 entries |
max_size | 2g | Total cache size limit on disk. Nginx removes old entries (LRU) when full |
inactive | 1h | Remove entries not accessed for 1 hour, even if not yet expired |
use_temp_path=off | - | Write directly to the cache directory, not via temp — more efficient |
# Create the cache directories with the correct owner
sudo mkdir -p /var/cache/nginx/{public,api}
sudo chown nginx:nginx /var/cache/nginx/
sudo chown nginx:nginx /var/cache/nginx/public /var/cache/nginx/api
Step 2: Enabling Cache in a Location #
server {
location /api/products/ {
proxy_pass http://backend;
# Use the zone defined earlier
proxy_cache api_cache;
# How long the cache is valid for various status codes
proxy_cache_valid 200 30m; # Success responses: 30 minutes
proxy_cache_valid 301 302 5m; # Redirects: 5 minutes
proxy_cache_valid 404 1m; # Not found: 1 minute
proxy_cache_valid any 0; # Other responses: not cached
# Debug header — show the cache status to the client
# HIT = from cache, MISS = from backend, BYPASS = skipped
add_header X-Cache-Status $upstream_cache_status always;
}
}
$upstream_cache_status Values
#
| Value | Meaning |
|---|---|
HIT | Response served from cache |
MISS | Not in cache, request to the backend |
BYPASS | Cache bypassed (per proxy_cache_bypass conditions) |
EXPIRED | In cache but expired, request to the backend to refresh |
STALE | Serving old cache because the backend errored (stale response) |
UPDATING | Entry being refreshed by another request (cache lock) |
REVALIDATED | Cache still valid based on a conditional request |
Cache Keys: What Distinguishes Cache Entries #
Nginx determines whether two requests are “the same request” based on the cache key. The default cache key is $scheme$proxy_host$request_uri.
You can customize this key to include other dimensions:
location /api/ {
proxy_pass http://backend;
proxy_cache api_cache;
# ─── Default key (usually sufficient for public APIs) ─────────────────────
proxy_cache_key "$scheme$request_method$host$request_uri";
# ─── Key with language: content differs by Accept-Language ──────
proxy_cache_key "$scheme$host$request_uri$http_accept_language";
# ─── Key with version: for A/B testing based on a custom header ──────
proxy_cache_key "$scheme$host$request_uri$http_x_app_version";
# ─── Key based on specific cookies: content differs per user-group ───
# Careful! This can make the cache very large if cookies have many variants
proxy_cache_key "$scheme$host$request_uri$cookie_user_segment";
}
Warning: Bad Cache Keys #
# DANGEROUS: a key including the session cookie
proxy_cache_key "$scheme$host$request_uri$http_cookie";
# → Every user with a different session = a different cache entry
# → The cache is useless (more MISSes than HITs)
# → The cache can leak data between users if cookies aren't hashed correctly
# CORRECT: bypass the cache for authenticated users (see the next section)
Cache Bypass Strategies: When Not to Cache #
Not every request should be cached. This is the most important part — a wrongly strategized cache can cause data leaks between users.
Basic Principles #
Cache → OK for:
✓ Public content that's the same for all users
✓ GET requests
✓ Responses that don't change often
✓ Responses from endpoints that don't depend on user identity
Cache → DON'T for:
✗ Personalized responses (user profiles, shopping carts)
✗ POST, PUT, DELETE, PATCH (mutating requests)
✗ Requests with an Authorization header or session cookie
✗ Responses containing sensitive data
✗ Endpoints returning real-time data
Implementing Cache Bypass #
http {
# ─── Detect authenticated users based on cookies ─────────────────────
map $http_cookie $no_cache_cookie {
default 0;
~*session_id= 1; # There's a session cookie
~*auth_token= 1; # There's an auth cookie
~*jwt= 1; # There's a JWT cookie
}
# ─── Detect HTTP methods that shouldn't be cached ───────────────────────
map $request_method $no_cache_method {
default 0;
POST 1;
PUT 1;
PATCH 1;
DELETE 1;
}
server {
location /api/ {
proxy_pass http://backend;
proxy_cache api_cache;
proxy_cache_valid 200 10m;
# proxy_cache_bypass: if the value is non-empty & non-zero → bypass
# (the request still goes to the backend, but the response is NOT stored in cache)
proxy_cache_bypass $no_cache_cookie $no_cache_method;
# proxy_no_cache: if the value is non-empty & non-zero
# → the response is not stored in cache
proxy_no_cache $no_cache_cookie $no_cache_method;
}
# ─── Public endpoint — can always be cached ─────────────────────────
location /api/public/ {
proxy_pass http://backend;
proxy_cache public_cache;
proxy_cache_valid 200 1h;
# No bypass — all users get the same response
}
# ─── Private endpoint — never cached ───────────────────────
location /api/user/ {
proxy_pass http://backend;
# Don't use proxy_cache at all for private endpoints
}
}
}
Bypass Based on the Client’s Cache-Control Header #
Browsers can request a fresh copy by sending Cache-Control: no-cache. You can honor this request:
location /api/ {
proxy_pass http://backend;
proxy_cache api_cache;
# Bypass if the client requests a fresh copy
# (Ctrl+Shift+R in browsers sends Cache-Control: no-cache)
proxy_cache_bypass $http_pragma $http_authorization $http_cache_control;
}
Stale Cache: Serving Old Cache When the Backend Errors #
One of the most useful features — Nginx can serve expired cache when the backend can’t be reached. Showing slightly old data is better than a 502 error.
location /api/ {
proxy_pass http://backend;
proxy_cache api_cache;
proxy_cache_valid 200 10m;
# Serve old (expired) cache if the backend:
# - Errors (502, 503, 500, 504)
# - Times out when connecting
# - Is being updated by another request
proxy_cache_use_stale error timeout updating
http_500 http_502 http_503 http_504;
# How long stale cache may be served
# (beyond the normal cache_valid time)
proxy_cache_revalidate on;
# Thundering herd protection:
# Only one request is sent to the backend to refresh an expired cache entry
# Other requests wait for that one request's result
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_lock_age 10s;
}
Background Update: Refreshing Cache Silently #
For content that changes periodically, you can tell Nginx to refresh the cache in the background when content is about to expire:
location /api/products/ {
proxy_pass http://backend;
proxy_cache api_cache;
proxy_cache_valid 200 5m;
# Serve from cache WHILE refreshing in the background
# Clients don't wait for the refresh — they get the response immediately
proxy_cache_background_update on;
# Don't delete the expired cache before a new version exists
proxy_cache_use_stale updating;
}
Honoring Cache Headers from the Backend #
Nginx can also use standard HTTP headers from the backend to determine how long the cache is valid:
# In the Node.js backend:
# res.set('Cache-Control', 'public, max-age=3600, s-maxage=7200');
# s-maxage: specifically for shared caches (including the Nginx proxy cache)
location /api/ {
proxy_pass http://backend;
proxy_cache api_cache;
# Use the Cache-Control value from the backend (default)
# If the backend doesn't set Cache-Control, use proxy_cache_valid
# Or: ignore the Cache-Control from the backend, use Nginx configuration
proxy_ignore_headers Cache-Control Expires Set-Cookie;
proxy_cache_valid 200 30m; # Nginx controls, backend ignored
}
Note: if the backend sends Set-Cookie, Nginx by default doesn’t store the response in cache (because a cookie usually means a personalized response). To ignore this:
location /api/public/ {
proxy_pass http://backend;
proxy_cache api_cache;
# Ignore Set-Cookie from the backend (if you're sure this endpoint isn't personalized)
proxy_ignore_headers Set-Cookie;
proxy_cache_valid 200 1h;
# Don't forward Set-Cookie to the client (since we already ignored it)
proxy_hide_header Set-Cookie;
}
Purging Cache #
Manually via Shell #
# See the cache contents
ls -la /var/cache/nginx/api/
# Delete all cache (careful — all subsequent requests will MISS)
sudo rm -rf /var/cache/nginx/api/*
# After deleting, no Nginx reload is needed — it rebuilds the cache automatically
Purge via API (Using the ngx_cache_purge Module) #
# Install the module (Ubuntu/Debian)
sudo apt install libnginx-mod-http-cache-purge
location ~ /purge(/.*) {
# Restrict purge access to internal servers only
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
# Purge the cache with a matching key pattern
proxy_cache_purge public_cache "$scheme$host$1";
}
# Purge a specific endpoint
curl -X PURGE http://example.com/purge/api/products
# The purge removes the cache entry for /api/products
# The next request to /api/products will MISS (fresh from the backend)
Invalidation Strategy via the Backend #
A safer alternative: the backend performs a purge via an HTTP request to Nginx after a data update:
# Python: after updating a product in the database
import requests
def invalidate_product_cache(product_id):
try:
requests.request(
"PURGE",
f"http://nginx-internal/purge/api/products/{product_id}",
timeout=5
)
except Exception as e:
logger.warning(f"Cache purge failed: {e}")
# Not critical — the cache expires on its own
Monitoring Cache Performance #
# Add a log format that includes the cache status
log_format cache_log '$remote_addr [$time_local] "$request" '
'$status $upstream_cache_status '
'$upstream_response_time $request_time';
# Calculate the hit rate from the log
# (run after a few hours of traffic)
awk '{print $6}' /var/log/nginx/access.log | sort | uniq -c | sort -rn
# Example output:
# 8543 HIT ← 85% hit rate (great!)
# 1203 MISS
# 254 BYPASS
# Cache hit rate = HIT / (HIT + MISS) × 100%
# Target: > 80% for frequently accessed public endpoints
# Check the cache size in use
du -sh /var/cache/nginx/
# Details per zone (if there's a specific log):
nginx -T | grep proxy_cache_path
Troubleshooting a Cache That Isn’t Working #
When the cache doesn’t work as expected, follow this checklist:
# 1. Check whether the cache directory exists and has correct permissions
ls -la /var/cache/nginx/
# Owner must be nginx:nginx or www-data:www-data
# 2. Make sure the zone is defined at the http level, not server/location
grep -n "proxy_cache_path" /etc/nginx/nginx.conf /etc/nginx/conf.d/*.conf
# 3. Check whether $upstream_cache_status is in the response
curl -sI https://example.com/api/products | grep -i "x-cache"
# If absent: make sure add_header X-Cache-Status $upstream_cache_status;
# 4. If always BYPASS: check the bypass conditions
# Add temporary debug headers:
# add_header X-Cache-Bypass-Cookie $no_cache_cookie always;
# add_header X-Cache-Bypass-Method $no_cache_method always;
# 5. Check whether the backend sends headers preventing caching
curl -sI http://localhost:3000/api/products | grep -iE "cache-control|pragma|set-cookie"
# "Cache-Control: no-store" or "Set-Cookie" from the backend prevents caching
# Use proxy_ignore_headers to ignore them
Table of Causes for a Non-Working Cache #
| Symptom | Likely Cause | Solution |
|---|---|---|
Always MISS | proxy_cache_bypass is satisfied | Check the map variable conditions |
Always MISS | Backend sends Cache-Control: no-store | Add proxy_ignore_headers Cache-Control |
Always BYPASS | proxy_no_cache is satisfied | Debug the bypass variable values |
| No X-Cache header | add_header not included | Add include snippets/security-headers.conf containing that header |
| Cache gone after restart | inactive too short | Increase the inactive value in proxy_cache_path |
| Cache not forming | Directory doesn’t exist | mkdir -p and set the correct ownership |
Summary #
- Define
proxy_cache_pathat thehttplevel before you can useproxy_cachein alocation. Use separate zones for different content types.$upstream_cache_statusas a debug header to see HIT/MISS/BYPASS — mandatory during the configuration phase.- Safe bypass strategy: use the
mapdirective to detect authenticated users (via cookies/headers), then setproxy_cache_bypassandproxy_no_cachetogether.proxy_cache_use_stale error timeoutserves old cache when the backend errors — making the application more resilient.proxy_cache_lock onprevents the thundering herd — only one request to the backend when many clients request the same expired cache entry at once.- Don’t cache POST/PUT/DELETE, responses with personal cookies, or endpoints containing sensitive data.
- For purging, use the
ngx_cache_purgemodule orrm -rfand let Nginx rebuild the cache automatically.