Rate limit requests in nginx
/etc/nginx/nginx.confhttp { limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s; limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; limit_req_status 429;
server { location / { limit_req zone=general burst=40 nodelay; } location /login { limit_req zone=login burst=3 nodelay; } }}The gotcha that makes this useless
Section titled “The gotcha that makes this useless”Read this before deploying anything above.
$binary_remote_addr is the address of whoever opened the TCP connection.
If nginx sits behind Cloudflare, an ALB, or any reverse proxy, that is the
proxy’s address — not the user’s.
The result is the opposite of what you wanted. Every visitor shares one bucket, so a burst of legitimate traffic trips the limit for everybody at once, while an attacker is throttled no more than anyone else. You have built a self-inflicted outage and called it protection.
Fix it by teaching nginx which address to trust:
set_real_ip_from 10.0.0.0/8; # your load balancer's rangereal_ip_header X-Forwarded-For;real_ip_recursive on;Then $binary_remote_addr becomes the client address and the limits mean
something.
set_real_ip_from must list only proxies you actually control. It tells nginx
to believe a header, and X-Forwarded-For is trivially forged. Trust it from an
arbitrary source and an attacker sets their own address per request, giving
themselves an unlimited rate — a rate limiter that any client can opt out of.
Why this is low severity
Section titled “Why this is low severity”Rate limiting is not an access control. It closes no exposure and stops no attacker who has a valid credential or a botnet with many addresses.
What it genuinely buys you:
- Origin protection. Your PHP-FPM pool or upstream app has a finite worker
count; nginx can absorb far more concurrency than it can.
limit_reqkeeps a crawler from exhausting the thing behind it. - Slower credential stuffing.
5r/mon/loginmakes online password guessing impractical. That’s real, and it’s why the login zone is separate.
What it does not buy you: protection from a real DDoS (that has to be absorbed upstream of your server, by a network that can hold the volume), or a substitute for authentication.
burst and nodelay
Section titled “burst and nodelay”The two parameters everyone gets wrong:
rate=20r/sis enforced per millisecond internally — 20r/s means one request every 50ms, not 20 arriving together. Withoutburst, a page loading 15 assets at once gets rejected immediately.burst=40allows a queue of 40 beyond the rate.nodelayserves that burst immediately rather than spacing it out. Withoutnodelay, excess requests are delayed to fit the rate, which turns a fast page into a slow one and looks like your server is broken.
For web traffic you almost always want burst with nodelay. Without both, the
limiter fires on ordinary page loads.
limit_req_status 429 is worth setting: nginx returns 503 by default, which
tells clients and CDNs “server error” rather than “you are being rate limited”.
429 is the correct code and well-understood by every HTTP client.
Related
Section titled “Related”- Add a default server block — dropping unwanted traffic before it costs anything.