Rate limiting caps how many requests a single client can make in a period of time. It protects login forms from brute force attacks, keeps APIs usable when one client misbehaves and absorbs small floods before they reach your application. Nginx implements it with the limit_req module, which is built into the Ubuntu package. In this tutorial you will configure a general per-IP limit, a stricter limit for a login endpoint, an allowlist for trusted addresses and a proper 429 response, then test that the limits work.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges. - Nginx installed (
sudo apt install nginx) with a site configured in/etc/nginx/sites-available/your_domain. The examples useyour_domainas the server name. - The
apache2-utilspackage on the machine you test from, for theabbenchmarking tool (optional).
Step 1 - Understanding how limit_req works
Nginx rate limiting uses the leaky bucket algorithm and needs two directives:
limit_req_zone(in thehttpcontext) defines a shared memory zone, the key that identifies a client and the rate allowed per key.limit_req(inhttp,serverorlocation) applies a zone to requests, optionally with a burst queue.
A rate of 10r/s does not mean "10 requests at any moment within a second". Nginx spreads it evenly: one request every 100 ms. Without burst, a second request arriving 50 ms after the first is rejected. The burst parameter lets a client exceed the rate briefly by queuing extra requests, and nodelay serves the queued requests immediately instead of spacing them out, while still counting them against the limit.
| Configuration | Behavior for a client sending 30 requests at once at 10r/s |
|---|---|
no burst | 1 served, 29 rejected |
burst=20 | 21 served, spaced 100 ms apart (last one after 2 s), 9 rejected |
burst=20 nodelay | 21 served immediately, 9 rejected, slots free up at 10 per second |
For websites and APIs, burst with nodelay is almost always what you want: browsers load many assets in parallel, and delaying them makes the page feel slow.
Step 2 - Defining the rate limit zones
Create a file in /etc/nginx/conf.d/, which Ubuntu's nginx.conf includes inside the http block:
sudo nano /etc/nginx/conf.d/rate-limit.conf
# General limit: 10 requests per second per client IP
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
# Strict limit for login and password reset: 5 requests per minute per IP
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
# Reply with 429 Too Many Requests instead of the default 503
limit_req_status 429;
limit_req_log_level warn;
The key $binary_remote_addr is the client IP in binary form, which uses less memory than the text $remote_addr. A 1 MB zone stores about 16,000 client states, so 10m holds about 160,000 IP addresses. When a zone is full, Nginx removes the oldest entries; if it still can't make room, it rejects the request.
limit_req_status 429 returns the status code that tells clients they are being rate limited, and limit_req_log_level warn writes rejected requests to the error log at warn level (delayed ones are logged one level lower).
Test the configuration:
sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Defining a zone does not limit anything yet; the next step applies it.
Step 3 - Applying the limits to your site
Open your site configuration:
sudo nano /etc/nginx/sites-available/your_domain
Apply the general limit to the whole server block, and the strict limit to the login endpoint. Adjust location = /login to the path your application uses (for example /wp-login.php for WordPress):
server {
listen 80;
server_name your_domain;
root /var/www/your_domain;
limit_req zone=perip burst=20 nodelay;
location = /login {
limit_req zone=login burst=5 nodelay;
# Keep passing the request to your application here, for example:
# proxy_pass http://127.0.0.1:8000;
}
location / {
try_files $uri $uri/ =404;
}
}
A limit_req directive in a location replaces the ones inherited from the server level, so /login is only limited by the login zone. If you want both limits on the same location, list both directives inside it; Nginx applies all of them and the strictest one wins.
Reload Nginx:
sudo nginx -t && sudo systemctl reload nginx
Step 4 - Testing the limits
Send 30 requests as fast as possible and count the status codes:
for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code}\n" http://your_domain/; done | sort | uniq -c
21 200
9 429
The first request plus the burst of 20 are served, and the rest are rejected. Wait a few seconds and run the loop again: the bucket has drained and requests are accepted.
Test the login limit the same way:
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code}\n" http://your_domain/login; done | sort | uniq -c
6 200
4 429
Only the first request and the burst of 5 get through. If no application answers on /login yet, the accepted requests return 404 instead of 200; what matters is that the last four return 429. Because the rate is 5 per minute, a new slot opens every 12 seconds.
For a load test with concurrency, ab reports how many responses were not 2xx:
ab -n 200 -c 20 http://your_domain/ | grep -E 'Complete requests|Non-2xx'
Complete requests: 200
Non-2xx responses: 179
Each rejected request is also logged in the error log:
sudo tail -n 3 /var/log/nginx/error.log
2026/09/25 10:42:17 [warn] 2318#2318: *845 limiting requests, excess: 20.300 by zone "perip", client: 203.0.113.25, server: your_domain, request: "GET / HTTP/1.1", host: "your_domain"
TipBefore enforcing limits on a busy production site, add
limit_req_dry_run on;next tolimit_req. Nginx then logs which requests would be rejected without rejecting them, so you can tune the rate and burst on real traffic first.
Step 5 - Excluding trusted clients
Monitoring systems, your office network or internal services should not be limited. Nginx does not account requests whose key is an empty string, so you can use geo and map to give trusted clients an empty key. Replace the general zone in /etc/nginx/conf.d/rate-limit.conf:
sudo nano /etc/nginx/conf.d/rate-limit.conf
geo $rate_limited {
default 1;
127.0.0.1 0;
10.0.0.0/8 0;
203.0.113.10 0;
}
map $rate_limited $rate_limit_key {
0 "";
1 $binary_remote_addr;
}
limit_req_zone $rate_limit_key zone=perip:10m rate=10r/s;
Replace 203.0.113.10 and the private range with your own trusted addresses. Keep the login zone and the other directives from Step 2 as they were. Reload Nginx and repeat the test loop from a trusted address: all 30 requests now return 200.
Step 6 - Limiting concurrent connections
limit_req controls the request rate, but a client can also hold many slow connections open, for example while downloading large files. The limit_conn module limits the number of simultaneous connections per key. Add the zone to /etc/nginx/conf.d/rate-limit.conf:
limit_conn_zone $binary_remote_addr zone=connperip:10m;
limit_conn_status 429;
Then apply it where needed, for example to a downloads location in your site:
location /downloads/ {
limit_conn connperip 5;
}
Each IP can now keep at most five downloads running at the same time. Test and reload as before.
Step 7 - Returning a useful 429 response
By default Nginx returns its generic HTML error page. For an API, return JSON and a Retry-After header so well-behaved clients know when to try again. Add this to the server block of your site:
error_page 429 = @ratelimited;
location @ratelimited {
default_type application/json;
add_header Retry-After 1 always;
return 429 '{"detail": "Too many requests, please slow down."}';
}
Reload Nginx, trigger the limit with the loop from Step 4 and look at one rejected response:
for i in $(seq 1 25); do curl -s -o /dev/null http://your_domain/; done; curl -i http://your_domain/
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 1
{"detail": "Too many requests, please slow down."}
Step 8 - Rate limiting behind a proxy or CDN
If Nginx sits behind a load balancer or a CDN such as Cloudflare, every request arrives from the proxy's IP address, and all visitors share a single limit. Use the realip module, which is included in Ubuntu's Nginx, to restore the client address before the limits are evaluated. Add this to the http context, for example in /etc/nginx/conf.d/real-ip.conf:
set_real_ip_from 10.0.0.5;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Replace 10.0.0.5 with the address or range of your proxy and use one set_real_ip_from line per range. Only list proxies you control or trust: any client that can reach Nginx directly from a listed address can set its own IP. For Cloudflare, list its published IP ranges and use real_ip_header CF-Connecting-IP;.
After reloading, the error log should show real client addresses in client: rather than the proxy's address.
Step 9 - Banning repeat offenders with Fail2Ban
Rate limiting rejects excess requests but does not stop a client from trying again. Fail2Ban includes a filter for the "limiting requests" messages in the Nginx error log, and can ban clients that keep hitting the limit at the firewall level:
sudo apt install fail2ban
sudo nano /etc/fail2ban/jail.d/nginx-limit-req.local
[nginx-limit-req]
enabled = true
backend = auto
maxretry = 10
findtime = 10m
bantime = 1h
Restart Fail2Ban and check the jail:
sudo systemctl restart fail2ban
sudo fail2ban-client status nginx-limit-req
Remember to exclude your own address with an ignoreip = 127.0.0.1/8 203.0.113.10 line in the jail if you test from it.
Troubleshooting
Limits never trigger. Check that the limit_req directive is in the server or location that actually handles the request (sudo nginx -T | grep -n limit_req shows the full effective configuration), and that the client key is not empty because of the allowlist.
All visitors are limited together. Nginx sees the IP address of a proxy or CDN. Configure the realip module as in Step 8.
Legitimate users get 429 when loading pages. The burst is too small for the number of parallel requests a page triggers (images, scripts, API calls). Increase burst, keep nodelay, or exclude static assets from the general limit by setting limit_req only in the locations that serve dynamic content.
nginx -t fails with zero size shared memory zone. A limit_req directive references a zone name that is not defined by any limit_req_zone. Check the spelling in both places.
Conclusion
Nginx now limits every client to a steady request rate with room for bursts, applies a much stricter limit to the login endpoint, skips trusted addresses, limits concurrent downloads and answers with a clear 429 response. As next steps, watch the error log for a few days and tune rate and burst to your real traffic, add separate zones for expensive API endpoints, and combine rate limiting with the rest of your Nginx hardening such as security headers and modern TLS settings.
