Nginx Rate Limiting
Two tools: limit_req (request rate per key) and limit_conn (concurrent connections per key). Both are leaky-bucket style and operate on a key (typically client IP).
limit_req — requests per second
http {
# Define a zone — must be in `http` context
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend;
}
}
}
Anatomy:
| Directive | Meaning |
|---|---|
$binary_remote_addr |
the key — client IP in compact binary form (4 bytes IPv4, 16 IPv6) |
zone=api:10m |
shared memory zone named api, 10MB (~160k IP slots) |
rate=10r/s |
sustained limit: 10 requests per second per key |
burst=20 |
token bucket size: short bursts up to 20 above the rate |
nodelay |
serve burst immediately (without delay); without it, burst requests are queued |
How leaky bucket actually works
It’s a token bucket variant:
- Tokens trickle into the bucket at
rate(10/s). - Each request consumes 1 token.
- Bucket capacity is
1 + burst(orburstwithnodelay). - No tokens? Reject (503) or delay (default).
So rate=10r/s burst=20:
- Steady state: 10 req/s allowed.
- After idle, you can burst up to 20 immediately.
- Then slow down to 10/s sustained.
nodelay says “serve the burst immediately without queuing.” Without nodelay, requests in the burst are delayed to fit the rate (artificially slow). nodelay is what most teams want for APIs.
What gets rejected
503 Service Temporarily Unavailable (or 429 if you set limit_req_status 429):
limit_req_status 429;
Browsers handle 503 oddly (some retry); 429 is the spec-correct “rate limited.” Set 429 for APIs.
Multiple zones
Combine to enforce different limits:
limit_req_zone $binary_remote_addr zone=ip_general:10m rate=100r/s;
limit_req_zone $binary_remote_addr zone=ip_login:10m rate=5r/m; # 5/min for /login
server {
limit_req zone=ip_general burst=200 nodelay; # all paths
location /login {
limit_req zone=ip_login burst=10 nodelay; # extra strict
}
}
Both apply at /login — must satisfy both. The stricter one bites first.
Rate limit by key other than IP
# Per API key (in header)
limit_req_zone $http_x_api_key zone=apikey:10m rate=100r/s;
# Per session cookie
limit_req_zone $cookie_session zone=session:10m rate=50r/s;
# Per server (global rate limit)
limit_req_zone $server_name zone=global:10m rate=10000r/s;
If the key is empty (e.g. no X-API-Key sent), the limit is bypassed for that request — be careful. Combine with auth to enforce key presence.
limit_conn — concurrent connections
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
server {
location /downloads/ {
limit_conn conn_per_ip 5; # max 5 concurrent connections per IP
}
}
For long-lived requests (downloads, video streaming, WebSockets), limit_conn prevents one user from monopolizing connections. limit_req doesn’t help here because connections are rare but long.
For high-volume short HTTP request workloads, limit_req is the right tool. For streaming / downloads, limit_conn is.
IP allowlists / blocklists
location /admin/ {
allow 10.0.0.0/8;
allow 192.168.1.0/24;
deny all;
proxy_pass http://backend;
}
Order matters — first match wins. The classic pattern: allow specific ranges, then deny all.
For dynamic blocklists, use geo or map:
geo $blocked_ip {
default 0;
192.0.2.0/24 1;
198.51.100.5 1;
}
server {
if ($blocked_ip) {
return 403;
}
}
For “ban this IP for 1 hour” you need limit_req or external tools (fail2ban, Cloudflare).
Behind a load balancer / CDN
$remote_addr is the immediate client — when behind ALB / CloudFront / nginx-in-front-of-nginx, that’s the proxy, not the user. Everyone gets rate-limited as one bucket.
Fix:
# trust your proxy's IP range
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
real_ip_header X-Forwarded-For;
real_ip_recursive on; # walk back through XFF chain
Now $remote_addr is the real client IP from X-Forwarded-For, and rate limiting works per-user.
Important: only do this when actually behind a trusted proxy. Otherwise clients can spoof X-Forwarded-For and bypass rate limits.
For CloudFront, the IP ranges are documented and updated. Cloudflare similar. AWS Security Groups/NACLs can enforce that only the CDN can reach the origin (defense in depth).
Rate limit and cache interaction
limit_req is checked before cache lookup. So a cached response still counts against the limit.
Workaround for high-traffic CDN’d endpoints: rate limit on proxy_cache_status != HIT only, via map:
map $upstream_cache_status $rate_limit_skip {
HIT 1;
default 0;
}
# limit_req doesn't directly accept conditions; use limit_req_status with $rate_limit_skip
# or skip via a `set $limit_req $...; if ($rate_limit_skip) { ... }` pattern
Or simpler: rate limit at the CDN edge (Cloudflare WAF rules), not at nginx.
Logging rejected requests
limit_req_log_level warn;
limit_conn_log_level warn;
Logs to nginx’s error.log. Watch for legitimate users hitting your limits — your limits are too tight.
Common pitfalls
- No
nodelayand high burst — burst requests get queued and “succeed slowly.” Confusing for API clients. - Zone too small —
10mholds ~160k IPv4 slots. Sites with millions of unique IPs need bigger zones, or LRU eviction kicks in randomly. - Behind a proxy without
set_real_ip_from— every request looks like it’s from the proxy IP. Either no rate limit or everyone shares one bucket. - Rate limit on protected static assets — uses up CPU rejecting legitimate page-load asset requests. Apply to
/api/only. $cookie_sessionas key for unauthenticated traffic — empty cookie means key is empty means no limit applies.
What rate limiting is NOT
Not a security feature. Not DDoS protection (large attacks overwhelm nginx itself before limits trigger).
For real protection:
- Cloudflare / CloudFront / AWS Shield in front.
- Application-level limits (per-user, per-tenant) inside your app.
- Auth gates that make brute-forcing expensive.
limit_req is for “polite” abuse and accidental hammering — runaway scripts, misconfigured retry loops, slightly aggressive integrations.
Common interview confusions
- “
burst=Nmeans N requests/sec extra.” — burst is the bucket size (peak above rate); rate is the sustained limit.rate=10r/s burst=20= 20-request peak, 10/s sustained. - “
nodelaylets you exceed the rate forever.” — only for the size of the burst. After the burst is consumed, requests are throttled torate. - “
$remote_addralways shows the user’s IP.” — only when there’s no proxy in front. Behind ALB/Cloudflare,$remote_addris the proxy’s IP unless you configurereal_ip. - “Nginx rate limiting prevents DDoS.” — partial; effective for low/medium-volume abuse. Real DDoS hits before the limit logic runs (the TCP/SSL handshake itself is the cost).
Interview angle
- “How do you rate-limit an endpoint to 10 req/s per IP?” — define
limit_req_zone $binary_remote_addr zone=name:10m rate=10r/sinhttp, thenlimit_req zone=name burst=20 nodelayin the location. Burst absorbs spikes; nodelay serves them immediately. - “
limit_reqvslimit_conn?” —limit_reqcaps requests per second (leaky bucket);limit_conncaps simultaneous open connections. Use req for HTTP APIs, conn for downloads/streaming/WebSockets. - “Why is
nodelaytypical?” — without it, burst requests are queued (delayed to fit the rate). API clients perceive slow responses. With nodelay, they’re served instantly until burst is exhausted. - “You’re behind a CDN — why doesn’t IP-based rate limiting work?” —
$remote_addris the CDN’s IP. Configureset_real_ip_from <CDN range>andreal_ip_header X-Forwarded-Forso nginx uses the original client IP from the header. - “What’s a good rate limit zone size?” — 10MB ≈ 160k IPv4 slots, fine for medium sites. For larger, increase or accept LRU evictions. Each entry is ~64 bytes.
- “Rate limiting as DDoS protection?” — only for low-volume abuse; real attacks need CDN/WAF/AWS-Shield-level protection upstream of nginx.