Nginx Proxy Cache for Reverse Proxy
Nginx proxy cache stores upstream server responses on disk, enabling Nginx to serve repeated requests directly without forwarding them to the backend. This is essential for reducing load on upstream application servers and improving response times for API gateways and reverse proxy deployments. This guide covers cache zone setup, key design, cache control headers, stale content serving, purging, and cache lock optimization.
Prerequisites
- Nginx installed (1.18+ recommended; 1.20+ for best performance)
- An upstream server or application (Node.js, Python, PHP, etc.)
- Root or sudo access
- Sufficient disk space for cache (SSD recommended for best performance)
Proxy Cache Zone Configuration
Define the cache zone in the http block:
sudo nano /etc/nginx/nginx.conf
http {
# Proxy cache zone definition
# path: where cached files are stored
# levels=1:2: two-level directory structure
# keys_zone=PROXYCACHE:10m: name and key storage (10MB ≈ 80,000 keys)
# inactive=10m: evict entries not accessed in 10 minutes
# max_size=5g: maximum total disk usage
# use_temp_path=off: write directly to cache path (better I/O)
proxy_cache_path /var/cache/nginx/proxy
levels=1:2
keys_zone=PROXYCACHE:10m
inactive=10m
max_size=5g
use_temp_path=off;
# Shared memory zone for cache metadata (separate from key storage)
# This is included in the keys_zone above
# Temp file settings
proxy_temp_path /var/cache/nginx/proxy_temp;
# Default proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Create directories:
sudo mkdir -p /var/cache/nginx/proxy /var/cache/nginx/proxy_temp
sudo chown -R www-data:www-data /var/cache/nginx/
Enable Cache in a Server Block
# /etc/nginx/sites-available/api-proxy
upstream backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
server_name api.example.com;
# Variables for cache bypass
set $bypass_cache 0;
# Bypass for non-GET/HEAD methods
if ($request_method !~ ^(GET|HEAD)$) {
set $bypass_cache 1;
}
# Bypass for authenticated requests (if your API uses auth headers)
if ($http_authorization != "") {
set $bypass_cache 1;
}
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection ""; # Enable keepalive
# Cache directives
proxy_cache PROXYCACHE;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 201 5m; # Cache 200/201 for 5 minutes
proxy_cache_valid 301 302 1m; # Cache redirects for 1 minute
proxy_cache_valid 404 30s; # Cache 404s for 30 seconds
proxy_cache_valid any 0; # Don't cache other status codes
proxy_cache_bypass $bypass_cache;
proxy_no_cache $bypass_cache;
# Pass cache status to client
add_header X-Cache-Status $upstream_cache_status always;
add_header X-Cache-Key "$scheme$request_method$host$request_uri" always;
}
# Never cache write operations
location ~* ^/(api/v[0-9]+/)?(create|update|delete|write) {
proxy_pass http://backend;
proxy_cache off;
}
}
sudo nginx -t && sudo systemctl reload nginx
# Test cache behavior
curl -I http://api.example.com/api/v1/users
# X-Cache-Status: MISS (first request)
curl -I http://api.example.com/api/v1/users
# X-Cache-Status: HIT (subsequent requests)
Cache Key Design
The cache key determines cache uniqueness. Design it carefully:
# Default key - good for most use cases
proxy_cache_key "$scheme$request_method$host$request_uri";
# Include query parameters (sorted - requires Lua or OpenResty for exact sorting)
proxy_cache_key "$scheme$request_method$host$uri$is_args$args";
# Vary by custom header (e.g., API version)
proxy_cache_key "$scheme$request_method$host$request_uri$http_x_api_version";
# Vary by Accept header for content negotiation (JSON vs XML)
proxy_cache_key "$scheme$request_method$host$request_uri$http_accept";
# Vary by geographic region (using GeoIP variable)
# proxy_cache_key "$scheme$request_method$host$request_uri$geoip_country_code";
# Example: strip common tracking params from cache key
# Use map to normalize the URI before using as key
map $request_uri $normalized_uri {
~^(/[^?]+)\?(?:.*&)?(utm_source|utm_medium|utm_campaign|fbclid)=[^&]*(&|$)(.*)$ $1?$4;
default $request_uri;
}
# Then use: proxy_cache_key "$scheme$request_method$host$normalized_uri";
Cache Control Header Handling
Respect upstream Cache-Control headers:
location /api/ {
proxy_pass http://backend;
proxy_cache PROXYCACHE;
# Respect upstream Cache-Control: no-cache, no-store
proxy_cache_bypass $http_pragma $http_authorization;
# Ignore Cache-Control headers from upstream for overriding TTL
# Use this to cache even if upstream says no-cache (use carefully)
# proxy_ignore_headers Cache-Control Expires;
# Override upstream cache headers for specific paths
proxy_hide_header Cache-Control;
add_header Cache-Control "public, max-age=300";
# Send cache headers to client
add_header X-Cache-Status $upstream_cache_status always;
}
# For APIs that don't set proper cache headers
location /api/v1/public/ {
proxy_pass http://backend;
proxy_cache PROXYCACHE;
proxy_cache_valid 200 5m;
# Force caching even if upstream says no-cache
proxy_ignore_headers Cache-Control X-Accel-Expires Expires;
proxy_cache_bypass 0;
add_header X-Cache-Status $upstream_cache_status always;
}
Stale Content and Grace Periods
Serve stale content to maintain availability during backend issues:
location / {
proxy_pass http://backend;
proxy_cache PROXYCACHE;
proxy_cache_valid 200 5m;
# Serve stale content in these situations
proxy_cache_use_stale
error
timeout
updating
http_500
http_502
http_503
http_504;
# Update stale cache in background (zero latency for client)
proxy_cache_background_update on;
# Lock prevents multiple simultaneous requests from hitting backend
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_lock_age 10s;
# How long stale content can be served (beyond inactive time)
# Not a standard nginx directive - use inactive in cache_path
add_header X-Cache-Status $upstream_cache_status always;
}
Cache Purging
Method 1: File-based purging (no extra module):
# Purge entire cache
sudo find /var/cache/nginx/proxy -type f -delete
# Purge by computing cache key hash
# Key format: scheme + method + host + URI
echo -n "GEThttps://api.example.com/api/v1/users" | md5sum
# Returns: d41d8cd98f00b204e9800998ecf8427e
# Cache file path: /var/cache/nginx/proxy/<last1>/<last3-2>/<md5>
# e.g.: /var/cache/nginx/proxy/e/7e/d41d8cd98f00b204e9800998ecf8427e
Method 2: Nginx Cache Purge module:
# Install Nginx with purge module
sudo apt install -y libnginx-mod-http-cache-purge # Ubuntu
# Add purge endpoint
location ~ /purge(/.*) {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
proxy_cache_purge PROXYCACHE "$scheme$request_method$host$1";
}
# Purge specific URLs
curl -X PURGE http://localhost/purge/api/v1/users
curl -X PURGE http://localhost/purge/api/v1/products
# Purge from application on content change
# Add to deployment script:
after_deploy() {
local paths=("/api/v1/products" "/api/v1/categories" "/api/v1/settings")
for path in "${paths[@]}"; do
curl -s -X PURGE "http://localhost/purge${path}"
echo "Purged: ${path}"
done
}
Cache Lock Optimization
The cache lock prevents the "thundering herd" problem where many requests hit the backend simultaneously for an uncached resource:
location / {
proxy_pass http://backend;
proxy_cache PROXYCACHE;
# Only one request goes to backend for a cache miss
# Others wait for the lock
proxy_cache_lock on;
# How long to wait for the lock before making a direct request
proxy_cache_lock_timeout 5s;
# After this time, create a background refresh request
proxy_cache_lock_age 5s;
# For high-traffic endpoints, combine with background update
proxy_cache_background_update on;
proxy_cache_use_stale updating;
}
Monitoring Cache Performance
# Enable Nginx stub_status for basic metrics
# Add to server block:
location /nginx_status {
stub_status on;
allow 127.0.0.1;
deny all;
}
# Check cache hit/miss ratio from access logs
# Log format should include $upstream_cache_status
# Add to nginx.conf http block:
log_format cache '$remote_addr - $upstream_cache_status [$time_local] "$request" $status';
access_log /var/log/nginx/cache.log cache;
# Analyze hit ratio
awk '{print $3}' /var/log/nginx/cache.log | sort | uniq -c | sort -rn
# Output:
# 45231 HIT
# 5123 MISS
# 892 BYPASS
# 231 EXPIRED
# Calculate hit ratio
awk 'BEGIN{hit=0;miss=0} /HIT/{hit++} /MISS/{miss++} END{print "Hit ratio:", hit/(hit+miss)*100"%"}' \
/var/log/nginx/cache.log
# Check cache disk usage
du -sh /var/cache/nginx/proxy/
Troubleshooting
Cache always shows MISS:
# Check if cache directory is writable
ls -la /var/cache/nginx/
# Verify cache entries are being created
ls -la /var/cache/nginx/proxy/ | head -10
# Check for cache bypass triggers
curl -v http://api.example.com/ 2>&1 | grep -i "authorization\|pragma"
# Check upstream response headers
curl -v http://127.0.0.1:3000/ 2>&1 | grep -i "cache-control\|pragma"
# If upstream sends: Cache-Control: no-store, nginx won't cache it
# Use proxy_ignore_headers to override
# Enable debug logging
# In nginx.conf: error_log /var/log/nginx/error.log debug;
Cache size grows unbounded:
# Verify max_size is set
grep -r "max_size" /etc/nginx/
# Nginx automatically evicts old entries when max_size is reached
# Check current size vs limit
du -sh /var/cache/nginx/proxy/
Backend overloaded despite cache:
# Check which requests are bypassing cache
grep BYPASS /var/log/nginx/cache.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -20
# Fix bypass conditions (too broad bypass rules)
# Common issue: all requests with cookies bypass cache
Conclusion
Nginx proxy cache is a powerful tool for reducing upstream server load and improving response latency for frequently requested resources. Carefully designed cache keys ensure correct cache partitioning, stale content serving maintains availability during backend failures, and the cache lock prevents thundering-herd problems during high traffic. Monitor cache hit ratios via access log analysis to validate that your cache configuration is delivering real-world benefits.


