Rate Limiting in nginx
A login form getting hammered by a credential-stuffing bot, a public API that one client calls in a tight loop, a search page that costs 200 ms of database time per hit: all of these are cases where you want nginx to slow down or reject excess requests before they reach PHP or the backend. nginx's limit_req module does this with a leaky-bucket algorithm and a few directives, and it has been in the core build forever, so there is nothing extra to install.
I got tired of watching that in the logs. Below is per-IP rate limiting, an explanation of burst and nodelay (the two parameters that decide whether legitimate users get hurt), a connection limit for downloads, and a whitelist for monitoring.
Check nginx -v first. The delay= parameter needs 1.15.7; Ubuntu 18.04's 1.14 handles everything else here. And if nginx sits behind a load balancer or Cloudflare, get the real client address in through the realip module, or you'll rate-limit the balancer's IP and everyone with it. I did exactly that once.
Define a zone in the http block
The zone holds the state (one entry per key) in shared memory across worker processes. It must be declared in the http {} context, so put it in /etc/nginx/conf.d/ratelimit.conf:
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_status 429;
limit_req_log_level warn;
$binary_remote_addr is the client IP in a compact 4-byte (IPv4) form; 10 MB of zone stores roughly 160,000 addresses. rate=5r/m means five requests per minute, tracked at millisecond granularity, so effectively one every 12 seconds. limit_req_status 429 returns Too Many Requests instead of the default 503, which is more honest and stops monitoring from treating it as an outage.
Apply the limit to a location
In the server block for the site:
location = /login {
limit_req zone=login burst=5 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php7.2-fpm.sock;
}
location /api/ {
limit_req zone=api burst=20 delay=10;
proxy_pass http://127.0.0.1:8080;
}
The parameters decide the user experience:
- Without
burst, any request arriving faster than the rate is rejected immediately. Real browsers fetch pages in bursts, so this alone produces false positives. burst=Nallows N requests over the rate to queue. By default they are delayed so that they are released at exactly the configured rate. For the login form that means the sixth attempt within a minute is queued for up to 12 seconds, which is fine.nodelayaccepts the burst requests immediately instead of queuing them, but still counts them against the bucket; further requests beyond the burst get 429 until the bucket drains. This is the usual choice for API-style endpoints where a delayed response is worse than a rejected one.delay=10(nginx 1.15.7+) is the middle ground: the first 10 burst requests are served immediately, the remaining 10 are delayed. This lets a page load its handful of API calls at once while still throttling sustained abuse.
Concurrent connections for heavy endpoints
Rate limiting counts requests; for large downloads or long-polling you want to cap simultaneous connections instead. Add a second zone type:
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_status 429;
and in the location:
location /downloads/ {
limit_conn perip 3;
limit_rate 2m;
root /var/www/files;
}
limit_conn perip 3 allows three concurrent connections per IP, and limit_rate 2m caps each of them at 2 MB/s. Together they stop one client from saturating the uplink.
Exempting trusted addresses
You don't want the health checker or your office IP hitting 429. Use geo to produce an empty key for trusted sources; limit_req skips requests whose key is empty:
geo $limit_key {
default $binary_remote_addr;
10.0.1.0/24 "";
203.0.113.5 "";
}
limit_req_zone $limit_key zone=api:10m rate=10r/s;
Replace the earlier api zone definition with this one. Requests from the listed ranges are no longer counted at all.
Reload and test
sudo nginx -t
sudo systemctl reload nginx
Fire more requests than the limit allows and count the status codes. For the login endpoint (5r/m, burst 5, nodelay):
for i in $(seq 1 12); do curl -s -o /dev/null -w '%{http_code}\n' https://example.com/login; done | sort | uniq -c
Expected: around 6 requests return 200 (the first at the base rate plus 5 burst) and the rest 429. For the API zone, a tool that runs in parallel gives a clearer picture:
ab -n 200 -c 20 https://example.com/api/status
Then check the log for the limit messages, which include the zone name and the client address:
sudo grep 'limiting requests' /var/log/nginx/error.log | tail -3
A line looks like: limiting requests, excess: 5.320 by zone "api", client: 198.51.100.7, server: example.com, request: "GET /api/status HTTP/1.1".
Gotchas
nginx: [emerg] "limit_req_zone" directive is not allowed here: the zone was declared inside aserverorlocationblock. It belongs inhttp {}.- Everyone gets 429 after moving behind a proxy: all traffic shares the proxy's address. Configure
set_real_ip_from 10.0.0.0/8; real_ip_header X-Forwarded-For;and use$binary_remote_addras before, or key on$http_x_forwarded_for(only if the header cannot be spoofed by clients reaching nginx directly). - Limits seem more lenient than configured on a busy site: each
limit_req_zoneis shared across workers, but if you defined the same zone name twice in different files nginx keeps the first and errors. Checknginx -T | grep limit_req_zone. limiting requests, dry runin the log: thedry_runparameter (1.17.1+) is set, which logs without enforcing. Useful for tuning, but remove it when you mean it.- Monitoring shows 503s instead of 429s:
limit_req_statuswas set in a different context than thelimit_req; it must be in scope for the location that applies the limit.
My advice: start generous, watch the error log for a week to see who trips the limits, then tighten.