Rate limiting caps how many requests a client can make in a given time. It slows down password guessing on login pages, stops a single scraper from exhausting your PHP workers, and keeps APIs fair. Nginx does it efficiently, before requests ever reach your application.
How it works
Nginx’s limit_req uses a leaky bucket: requests from each client (usually identified by IP) are allowed at a fixed rate. Short bursts can be queued or allowed; anything beyond is rejected with an error (503 by default; 429 is better).
Two parts:
limit_req_zonein thehttp {}context defines a shared memory zone, the key and the rate.limit_reqin aserverorlocationapplies it.
A basic login limit
In /etc/nginx/conf.d/ratelimit.conf (included in http {}):
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_status 429;
In the site config:
location = /wp-login.php {
limit_req zone=login burst=3 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
$binary_remote_addr— the client IP in compact binary form; 10 MB of zone memory holds roughly 160,000 addresses.rate=5r/m— five requests per minute per IP (one every 12 seconds on average).burst=3— allow up to 3 extra requests above the rate.nodelay— serve the burst immediately instead of spacing them out.
A human logging in never notices. A bot trying hundreds of passwords gets 429 Too Many Requests.
Remember: if the location previously handled PHP via a general \.php$ block, the exact-match location must include the PHP handling itself, as shown.
A general site-wide limit
Protect the whole site from aggressive crawlers without bothering normal visitors:
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
server {
location / {
limit_req zone=perip burst=40 nodelay;
try_files $uri $uri/ /index.php?$args;
}
}
A page load triggers many requests (CSS, JS, images), so keep general limits generous, or apply them only to dynamic requests (the PHP or proxy location) and leave static files unlimited.
burst and nodelay explained
With rate=10r/s burst=20:
- Without
nodelay: excess requests (up to 20) are queued and released at 10/s. Clients see added latency instead of errors. - With
nodelay: up to 20 excess requests are served at once, but the burst “slots” refill only at 10/s. Requests beyond the burst are rejected immediately. delay=8(two-stage): the first 8 excess requests are served immediately, the rest of the burst is delayed.
For web pages, nodelay usually gives the best user experience.
API limits
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
location /api/ {
limit_req zone=api burst=60 nodelay;
proxy_pass http://127.0.0.1:3000;
}
To limit by API key rather than IP:
limit_req_zone $http_x_api_key zone=apikey:10m rate=100r/s;
Requests with an empty key aren’t counted in that zone — combine it with an IP-based zone (several limit_req lines can apply to one location).
Limiting connections
limit_conn caps simultaneous connections per key — useful for downloads and slowloris-style abuse:
limit_conn_zone $binary_remote_addr zone=conn:10m;
location /downloads/ {
limit_conn conn 2;
limit_rate 2m; # also cap each connection's bandwidth to 2 MB/s
}
Whitelisting trusted IPs
Use a geo + map pair so your office, monitoring or internal services bypass limits:
geo $limited {
default 1;
127.0.0.1 0;
203.0.113.0/24 0; # office
}
map $limited $limit_key {
0 "";
1 $binary_remote_addr;
}
limit_req_zone $limit_key zone=perip:10m rate=10r/s;
An empty key means the request isn’t limited.
Behind Cloudflare or a load balancer
All requests appear to come from the proxy’s IPs, so one limit would apply to everyone together. Configure the real-IP module first, so $binary_remote_addr is the visitor’s IP:
set_real_ip_from 173.245.48.0/20; # …all Cloudflare ranges
real_ip_header CF-Connecting-IP;
See real IP behind Cloudflare.
Custom error page for 429
error_page 429 /429.html;
location = /429.html {
root /var/www/errors;
internal;
}
For APIs, return JSON and a Retry-After header from your app or a small static file.
Testing
for i in $(seq 1 15); do curl -s -o /dev/null -w "%{http_code} " https://example.com/wp-login.php; done; echo
You should see 200s, then 429s. Check the error log for the rejection messages:
sudo grep "limiting requests" /var/log/nginx/error.log | tail
Use limit_req_dry_run on; (Nginx 1.17.1+) to log what would be limited without rejecting anything — ideal for tuning on a live site.
Rate limiting vs other defenses
Rate limiting slows abuse from individual IPs. It doesn’t stop large distributed attacks — that needs upstream protection. Pair it with Fail2Ban, which bans IPs that keep triggering limits, and see Fail2Ban for Nginx.
More Nginx: location blocks and Nginx 504 timeouts, which bot floods often cause.