backend / protocols / nginx / 11_rate_limiting.md

Nginx Rate Limiting

6 interview angles 6 min read source

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 (or burst with nodelay).
  • 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 nodelay and high burst — burst requests get queued and “succeed slowly.” Confusing for API clients.
  • Zone too small10m holds ~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_session as 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=N means 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.
  • nodelay lets you exceed the rate forever.” — only for the size of the burst. After the burst is consumed, requests are throttled to rate.
  • $remote_addr always shows the user’s IP.” — only when there’s no proxy in front. Behind ALB/Cloudflare, $remote_addr is the proxy’s IP unless you configure real_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/s in http, then limit_req zone=name burst=20 nodelay in the location. Burst absorbs spikes; nodelay serves them immediately.
  • limit_req vs limit_conn?”limit_req caps requests per second (leaky bucket); limit_conn caps simultaneous open connections. Use req for HTTP APIs, conn for downloads/streaming/WebSockets.
  • “Why is nodelay typical?” — 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_addr is the CDN’s IP. Configure set_real_ip_from <CDN range> and real_ip_header X-Forwarded-For so 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.