Overview
Two Nginx features sit in the "everyone should use this, almost nobody does" category: proxy caching and rate limiting. Both are maybe ten lines of config. Both will save you from a bad day.
Caching saves you when a page goes viral. Rate limiting saves you when someone decides to hammer your login endpoint.
Caching a proxy
You need two pieces: a cache zone declared in the http block, and a proxy_cache directive in the server block.
# In http {} block
proxy_cache_path /var/cache/nginx
levels=1:2
keys_zone=my_cache:10m
max_size=1g
inactive=60m
use_temp_path=off;
| Parameter | Meaning |
|---|---|
levels=1:2 | Two-level directory hash — avoids one directory with a million files |
keys_zone=my_cache:10m | Name and size of the shared memory zone for keys. 10m holds about 80,000 keys. |
max_size=1g | Cap on disk usage. Nginx evicts oldest when exceeded. |
inactive=60m | Delete items not accessed in 60 minutes, even if not expired |
use_temp_path=off | Write directly to the cache directory — avoids a copy |
Then in the server block:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend;
proxy_cache my_cache;
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;
}
}
Three directives there are doing the real work:
proxy_cache_use_stale — if the backend is down, serve the stale cached copy instead of an error. This is the difference between a blip and an outage. If your backend hiccups for ten seconds, users see the site.
proxy_cache_background_update on — serve the stale version immediately and refresh the cache in the background. Users never wait for the refresh.
proxy_cache_lock on — if ten requests arrive for an uncached URL at once, only one goes to the backend. The other nine wait. Without this, a cache stampede can hammer your origin harder than no cache at all.
The X-Cache-Status header is your debugging tool. It returns HIT, MISS, BYPASS, EXPIRED, or STALE. If you're not seeing hits, look at the response headers from your backend — a Cache-Control: no-cache or Set-Cookie on every response will prevent caching.
What not to cache
# Never cache authenticated or personalized content
location ~ ^/(admin|api/user|login) {
proxy_pass http://backend;
proxy_cache off;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
# Don't cache anything with a session cookie
map $http_cookie $no_cache {
default 0;
~SESSID 1;
~session 1;
}
That map is the piece most people miss. Any response that sets a cookie is probably per-user and shouldn't be shared between users. The map returns 1 if a session cookie is present, and you can use it in proxy_cache_bypass $no_cache to skip the cache entirely for those requests.
Rate limiting
Rate limiting lives in two parts too. Define a zone in http, apply it in a location.
# In http {} block
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_status 429;
limit_conn_status 429;
$binary_remote_addr is the key — it's the client IP in binary form, which is compact and fast. There's also $remote_addr, which is a string and less efficient. Use the binary version.
The rate is per second, but you can express slower rates as r/m or r/h. If you want "one login attempt per minute per IP," that's rate=1r/m.
Apply them:
server {
# General traffic
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://backend;
}
# Stricter on the API
location /api/ {
limit_req zone=api burst=50 nodelay;
proxy_pass http://backend;
}
# Very strict on login
location = /login {
limit_req zone=login burst=3 nodelay;
proxy_pass http://backend;
}
}
The burst parameter is where most confusion comes from. It's a queue of excess requests that Nginx will accept and delay. With rate=10r/s and burst=20, a client can send 30 requests in one second — 10 are served immediately, and up to 20 wait in a queue. Anything beyond that gets a 429.
Add nodelay and those 20 queued requests are served as fast as possible rather than being artificially slowed. This is usually what you want for API traffic — you're preventing abuse, not shaping bandwidth.
Rate limiting by connection count
Rate limiting by request count doesn't help with slowloris attacks, where a client opens connections and just... doesn't finish. For that:
# In http {} block
limit_conn_zone $binary_remote_addr zone=addr:10m;
# In location {} block
limit_conn addr 20;
Twenty simultaneous connections per IP. Any legitimate browser stays well under that; a misbehaving scraper blows right through it.
The gotcha: real client IPs
If you're behind Cloudflare, or any load balancer, $binary_remote_addr is the load balancer's IP, not the client's. Every request appears to come from the same address and you'll rate-limit the entire internet at once.
Fix it with the realip module:
# In http {} block
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... full Cloudflare list
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
Then $binary_remote_addr reflects the actual client. The current IP list is at cloudflare.com/ips and does change occasionally — fetch it in a cron job rather than hardcoding.
Testing
Once configured, verify both:
# Rate limit: this should start returning 429 after burst+rate
for i in $(seq 1 50); do
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/login
done
# Cache: run twice and check the header
curl -I https://example.com/ | grep X-Cache-Status
If the rate limit isn't triggering, check that limit_req_status isn't being overridden lower in the config, and confirm the real IP is what you think it is by logging $binary_remote_addr temporarily.
Order of operations
One thing that surprises people: rate limiting happens before caching, in the request processing order. So a rate-limited request never reaches the cache. If you want cached content to bypass rate limits — which is reasonable, since serving from cache is cheap — put the cache check for a whitelist of paths and skip rate limiting for them. It's a niche optimization; the default order is fine for most sites.
