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;
ParameterMeaning
levels=1:2Two-level directory hash — avoids one directory with a million files
keys_zone=my_cache:10mName and size of the shared memory zone for keys. 10m holds about 80,000 keys.
max_size=1gCap on disk usage. Nginx evicts oldest when exceeded.
inactive=60mDelete items not accessed in 60 minutes, even if not expired
use_temp_path=offWrite 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.