Websites & apps

Rate limit requests in Nginx with limit_req

Slow down brute-force logins, scrapers and API abuse with Nginx limit_req and limit_conn, using burst and nodelay so real visitors are not affected.

IntermediateLinux server5 min read

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) or 10r/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:

Bash
sudo nano /etc/nginx/conf.d/rate-limits.conf
Nginx
limit_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_addr is the client IP in compact form, so the zone stores more entries.
  • login allows 10 requests per minute per IP, which is plenty for a human typing a password.
  • api allows 10 per second, general 20 per second. Adjust to your traffic.
  • limit_req_status 429 returns 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:

Nginx
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. At 10r/m, two clicks within six seconds would fail.
  • burst=5 lets up to five extra requests queue up. Nginx releases them at the configured rate.
  • nodelay serves 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

Nginx
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:

Nginx
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:

Nginx
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

Bash
sudo nginx -t && sudo systemctl reload nginx

Check it works

Send a quick series of requests to the login page and count the status codes:

Bash
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 -c

You should see some 200 responses and the rest 429. Rejected requests also appear in the error log:

Bash
sudo grep "limiting requests" /var/log/nginx/error.log | tail

Troubleshooting

  • 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 include and fastcgi_pass lines 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 -t says "zone is already bound": two limit_req_zone lines 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

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.