nginx: Rate Limiting with limit_req
Rate limiting caps how many requests a single client may send in a given time window. It protects login endpoints against brute-force attempts and keeps a single misbehaving client from exhausting your server — without any external tooling, using only built-in nginx modules.
This article is part of a three-layer hardening series for nginx:
- Block direct IP access — who is asking at all?
- Rate limiting (this article) — how often are they asking?
- ModSecurity + CRS — what are they sending?
Prerequisites
- nginx installed and serving at least one site
- Root or sudo access
- If the server sits behind Cloudflare or another reverse proxy: read the proxy section before enabling anything
How It Works
nginx rate limiting is split into two directives, and the separation matters:
limit_req_zonedefines a limit: which key identifies a client (usually the IP address), how much shared memory to reserve for tracking state, and the allowed rate. Defined once, in thehttp {}block.limit_reqapplies a defined zone to aserverorlocationblock. One zone can be applied in many places.
The algorithm is a leaky bucket: requests drain out at the configured rate. Two parameters control what happens when a client sends faster than that:
burst— how many excess requests may queue up before nginx starts rejecting. Without it, any request arriving faster than the rate is rejected immediately, which breaks normal browsing (a page load fires many requests at once).nodelay— serve queued burst requests immediately instead of trickling them out at the configured rate. For interactive sites this is almost always what you want: short bursts pass at full speed, sustained flooding still gets rejected.
Recommended Baseline
Define the zones globally in the http {} block:
# zone definitions — 1 MB stores ~16k IP states
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_status 429;
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_status 429;
$binary_remote_addr is the client IP in compact binary form — it uses less zone memory than the text form. limit_conn_zone additionally caps concurrent connections per IP, which complements the request rate limit.
Apply the zones where they are needed, inside your server {} block:
location /login {
limit_req zone=login burst=5 nodelay;
}
location / {
limit_req zone=general burst=60 nodelay;
limit_conn perip 30;
}
The pattern: strict on authentication endpoints, generous everywhere else. Ten login attempts per minute stops brute force cold while never bothering a human. The general limit exists to protect availability, not to ration usage — if real users ever hit it, raise it.
Validate and reload:
Why Status 429
By default nginx answers rate-limited requests with 503 Service Unavailable — which is semantically wrong (the service is fine, the client is too fast) and pollutes monitoring, since 503 usually pages someone. limit_req_status 429; and limit_conn_status 429; return the correct 429 Too Many Requests instead, which clients and monitoring systems understand.
Calibrate Safely with Dry-Run
Guessing limits and enforcing them immediately risks blocking legitimate users. nginx has the equivalent of a WAF's detection-only mode:
With dry-run active, nginx logs every request that would have been limited but rejects nothing. Run it for a few days of normal traffic, then check the error log:
Entries marked dry run show which clients and routes would have been affected. Adjust rate and burst until normal usage patterns produce no hits, then remove the dry-run directive.
For ongoing visibility, keep limit events at a sensible log level:
Behind Cloudflare or a Reverse Proxy
If a proxy sits in front of nginx, $binary_remote_addr contains the proxy's IP, not the client's. The result: all visitors share one rate-limit bucket, and you effectively rate-limit Cloudflare itself — blocking everyone at once.
The fix is the ngx_http_realip_module: it replaces the client address with the one the proxy reports, but only for requests arriving from explicitly trusted proxy ranges:
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
real_ip_header CF-Connecting-IP;
List every range your proxy uses — Cloudflare publishes its current ranges at cloudflare.com/ips. Never trust the header unconditionally: without set_real_ip_from, any client could spoof its own IP via the header and bypass the limit entirely.
Rate limiting is not DDoS protection
limit_req defends against brute force and individual abusive clients. Volumetric attacks saturate your uplink before nginx ever sees a packet — that class of attack must be handled in front of the server (Anycast, CDN, provider-level filtering).
Verify
Fire more requests than the limit allows and watch the status codes flip:
The first requests return 200 (or whatever the endpoint normally answers), then 429 appears once rate and burst are exhausted. The corresponding limiting requests entries show up in /var/log/nginx/error.log.
Common Issues
| Symptom | Cause | Fix |
|---|---|---|
| All users blocked at once | Server behind proxy, limiting the proxy IP | Configure real_ip with trusted proxy ranges |
| Normal page loads hit the limit | burst missing or too small |
Page assets arrive in bursts — set burst with nodelay |
| Monitoring alerts on 503 spikes | Default rejection status | Set limit_req_status 429; |
| Limits don't seem to apply | Zone defined but never applied | limit_req_zone only defines — add limit_req to the location |
could not allocate node in zone in error log |
Zone memory full | Increase the zone size (e.g. 10m → 20m) |