A single bot hammering your login page or API can slow a site down for everyone and try thousands of passwords an hour. Nginx can cap how fast each IP address may send requests, and reject the excess before it reaches PHP or your app. This guide sets up limit_req for logins and APIs, explains burst and nodelay, and adds limit_conn to cap open connections.
Before you start
- A Linux VPS with Nginx serving your site.
- SSH access with sudo.
- If the site is behind Cloudflare or another proxy, set up real IPs first. Otherwise every visitor shares the proxy's IP and gets limited together. See Show real visitor IPs in Nginx behind Cloudflare.
How limit_req works
Nginx uses a "leaky bucket". You define:
- a key, usually the client IP, so each visitor has their own bucket;
- a rate, such as
5r/s(five per second) or10r/m(ten per minute); - a zone, a slice of shared memory that stores the counters.
Requests that arrive faster than the rate are delayed or rejected. A 10 MB zone holds state for well over a hundred thousand IPs, which is plenty for one VPS.
Define the zones
Zones go in the http context. Create a file for them:
sudo nano /etc/nginx/conf.d/rate-limits.conflimit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;
limit_conn_zone $binary_remote_addr zone=peraddr:10m;
limit_req_status 429;
limit_conn_status 429;$binary_remote_addris the client IP in compact form, so the zone stores more entries.loginallows 10 requests per minute per IP, which is plenty for a human typing a password.apiallows 10 per second,general20 per second. Adjust to your traffic.limit_req_status 429returns 429 Too Many Requests instead of the default 503, which is more accurate and easier to spot in logs.
Protect a login page
Inside your site's server block, apply the login zone to the login URL. For WordPress:
location = /wp-login.php {
limit_req zone=login burst=5 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}Copy the PHP lines from your existing location ~ \.php$ block. The exact location = takes priority over the regex block, so this location must pass the request to PHP itself. On AlmaLinux and Rocky the PHP lines look different (often fastcgi_pass unix:/run/php-fpm/www.sock;), so copy yours exactly.
burst and nodelay explained
- Without
burst, any request faster than the rate is rejected immediately. At10r/m, two clicks within six seconds would fail. burst=5lets up to five extra requests queue up. Nginx releases them at the configured rate.nodelayserves those burst requests immediately instead of spacing them out, but still counts them. Once the burst is used up, further requests get 429.
For pages humans use, burst with nodelay feels normal to visitors while still stopping bots. Without nodelay, real users may see slow page loads.
Protect an API
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_conn peraddr 20;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}limit_conn peraddr 20 caps each IP at 20 simultaneous connections, which stops one client from opening hundreds of slow requests.
A gentle site-wide limit
You can also put a loose limit on the whole server block to catch aggressive scrapers:
server {
limit_req zone=general burst=50 nodelay;
}Add the line inside your existing server block. Keep it generous. A single page load can fetch dozens of images, scripts and fonts, so a tight limit here breaks normal browsing.
Exempt your own IPs
To skip limits for your office or monitoring service, use a geo and map in the http context, and use the result as the key. An empty key is not counted:
geo $limit_exempt {
default 0;
198.51.100.20 1;
}
map $limit_exempt $limit_key {
0 $binary_remote_addr;
1 "";
}
limit_req_zone $limit_key zone=login:10m rate=10r/m;Replace the earlier login zone line with this version rather than adding a second one.
Apply the change
sudo nginx -t && sudo systemctl reload nginxCheck it works
Send a quick series of requests to the login page and count the status codes:
for i in $(seq 1 20); do
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-login.php
done | sort | uniq -cYou should see some 200 responses and the rest 429. Rejected requests also appear in the error log:
sudo grep "limiting requests" /var/log/nginx/error.log | tailTroubleshooting
- Everyone gets 429 at once: Nginx sees all visitors as one IP, usually a CDN or load balancer. Configure the real IP module before rate limiting.
- Login page now downloads PHP or shows 404: the new exact location has no PHP handling. Copy the
includeandfastcgi_passlines from your PHP block. - Normal browsing triggers limits: the site-wide rate or burst is too low. Raise the burst, or limit only logins and APIs.
nginx -tsays "zone is already bound": twolimit_req_zonelines use the same zone name. Keep one.- Bots still get through slowly: rate limiting slows abuse but does not ban. Pair it with Fail2ban jails for Nginx to block repeat offenders.
Next steps
- Block known bad IPs outright: Allow or block IP addresses in Nginx.
- Lock down WordPress logins further: Protect wp-login.php.
