Rate limiting restricts how many requests a client may send in a given time and rejects the excess, ideally with HTTP 429. On your own server, nginx's limit_req module does the counting and fail2ban can firewall repeat offenders. A CDN such as Cloudflare does the same before traffic reaches you. It stops floods from few sources, not saturated links.
Rate limits fail in two ways. Either they’re so loose that nothing ever trips them, or they trip on the wrong thing. The second is worse, and it usually happens because the server is counting a proxy’s IP address rather than the visitor’s. One misread header and your whole user base shares a single 10-requests-per-second allowance.
What rate limiting is, and where it stops helping
Rate limiting counts requests per key, usually per client IP, and rejects anything above a set pace. nginx implements this with a leaky bucket: requests pour in, the bucket drains at a fixed rate, and overflow is rejected.
It works well against login brute-forcing, aggressive scrapers, HTTP floods from a manageable number of sources, and abuse of expensive endpoints like search, report exports or image resizing.
It does nothing against volumetric network floods. If 10 Gbit/s of UDP arrives at a 1 Gbit/s uplink, nginx never gets a say. For which attacks live at which layer, see types of DDoS attacks.
Starting values per endpoint
These are starting points for a typical CMS or shop, not standards. Check them against your own logs before going live.
| Endpoint | Rate per IP | Burst | Reasoning |
|---|---|---|---|
| Regular pages | 10r/s | 20 | One page view can pull 10+ assets if not served from a CDN |
| Login, password reset | 5r/m | 3 | Humans don’t submit five logins a minute |
| Search, filters | 2r/s | 5 | Each request hits the database |
| Authenticated API | per contract | 10–50 | Key-based limits beat IP-based ones |
| Static assets | none | none | Cache them; don’t count them |
To list the top 20 requests-per-IP-per-minute values in a combined-format access log:
awk '{split($4,t,":"); print $1, t[2]":"t[3]}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20
If your heaviest real visitor peaks at 90 requests a minute, 10r/s with a burst of 20 leaves plenty of room. Filter out known crawlers by user agent first, or they’ll skew the picture.
nginx rate limiting with limit_req and limit_conn
ngx_http_limit_req_module ships with standard nginx builds. The official documentation says a 1 MB zone holds about 16,000 64-byte states or 8,000 128-byte states; 64-bit systems use the larger size, so a 10 MB zone tracks roughly 80,000 addresses.
http {
# allowlist: requests with an empty key are not counted
geo $limit {
default 1;
10.0.0.0/8 0;
192.0.2.10/32 0; # uptime monitor
}
map $limit $limit_key {
0 "";
1 $binary_remote_addr;
}
limit_req_zone $limit_key zone=general:10m rate=10r/s;
limit_req_zone $limit_key zone=login:10m rate=5r/m;
limit_conn_zone $limit_key zone=perip:10m;
limit_req_status 429;
limit_conn_status 429;
limit_req_log_level warn;
server {
listen 443 ssl;
server_name example.com;
location / {
limit_req zone=general burst=20 nodelay;
limit_conn perip 30;
proxy_pass http://app;
}
location = /login {
limit_req zone=login burst=3 nodelay;
proxy_pass http://app;
}
}
}
Things worth knowing that most snippets skip:
- Without
nodelay, requests above the rate but within the burst are queued and delayed. Users experience that as a slow site. Withnodelaythey’re served immediately until the burst is used up. delay=N(nginx 1.15.7 and later) sits between the two: the first N excess requests go through at once, the rest are paced.- For rates below one per second, use requests per minute. Half a request per second is
30r/m. - The default rejection status is 503. Change it, or your monitoring will report outages that are really defence.
Test and reload with nginx -t && systemctl reload nginx.
Behind a CDN or load balancer, fix the client IP first
Behind any proxy, $binary_remote_addr is the proxy’s address. The real IP module swaps it for the client address from a header:
set_real_ip_from 173.245.48.0/20; # list every Cloudflare range
set_real_ip_from 2400:cb00::/32;
real_ip_header CF-Connecting-IP;
Only trust that header from the proxy’s own ranges. Otherwise anyone can claim any IP by sending the header themselves. Locking the origin down so only the CDN can reach it is covered in Cloudflare DDoS protection.
fail2ban for clients that keep hitting the limit
A rejected request still costs a TCP handshake, a TLS negotiation and a log line. fail2ban watches the nginx error log for “limiting requests” entries and moves persistent offenders into the firewall. The nginx-limit-req filter ships with fail2ban. Put the jail in /etc/fail2ban/jail.d/nginx-limit-req.local:
[nginx-limit-req]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
findtime = 600
maxretry = 10
bantime = 3600
Check it with fail2ban-client status nginx-limit-req.
One caveat: behind Cloudflare, the firewall only sees Cloudflare’s edge addresses. Ban one and you block a random slice of real visitors while the attacker carries on through another node. In that setup, blocking belongs at the edge.
Rate limiting at the edge
Cloudflare’s free plan includes one rate limiting rule. It counts per IP over a 10-second window, can match on path, and blocks for 10 seconds when triggered. Pro gives you two rules, Business five. The advantage over nginx is that rejected requests never reach your server.
A sensible setup layers both: coarse limits at the edge to absorb volume, precise limits in nginx for expensive paths. If you’re already under attack, though, tuning comes later. Start with what to do during a DDoS attack.
Signs your limits are wrong
- 429 spikes during business hours from consumer ISPs: too strict.
- Zero 429s during a known attack: wrong key (proxy IP) or the targeted path isn’t covered.
- Complaints from offices or universities: shared NAT addresses. Raise the burst or allowlist the range.
- Your monitor reports downtime: add its IPs to the allowlist.
To count 429 responses per minute:
awk '$9 == 429 {split($4,t,":"); print t[2]":"t[3]}' /var/log/nginx/access.log | uniq -c | tail
Rate limits protect the application. The domain behind it needs its own safeguards: registrar lock, DNSSEC, and account access with a second factor. For DNS resilience, add a secondary DNS provider. You can manage nameservers and DNSSEC at betadomain, automate it through the registrar API for developers, or check fixed renewal prices first.
Step by step
- Measure normal trafficCount requests per IP per minute in a normal day's access log and note the busiest legitimate clients.
- Define zonesAdd limit_req_zone entries for general traffic and for login in the http block, and set limit_req_status to 429.
- Apply limits to locationsAdd limit_req with burst and nodelay to the relevant location blocks, then run nginx -t and reload.
- Fix the client IP behind proxiesSet set_real_ip_from and real_ip_header so nginx counts visitors, not your CDN or load balancer.
- Add fail2ban for repeat offendersEnable the nginx-limit-req jail so IPs that keep hitting the limit are blocked at the firewall for an hour.
Found your domain? You can check and register it directly at register a domain – yearly price from €2.99.
FAQ
What is a good rate limit for a login page?
Around five requests per minute per IP with a small burst is a common starting point, because people rarely submit a login form more often than that. Watch your logs for a week and adjust. Password reset endpoints deserve the same treatment.
Should rate limiting return 429 or 503?
429 Too Many Requests, defined in RFC 6585, is the accurate status. nginx defaults to 503, which monitoring tools and crawlers read as a server failure. Set limit_req_status 429 and consider sending a Retry-After header.
Can rate limiting stop a DDoS attack?
It handles application-layer floods from a limited number of sources and protects expensive endpoints. It cannot help when the attack fills your network link before packets reach the web server; that needs upstream filtering from your provider or a CDN.
Why does my rate limit block everyone at once?
Your server is probably counting the address of a proxy or load balancer rather than the visitor. Configure the real IP module so nginx reads the client address from the proxy's header, and only trust that header from the proxy's own ranges.