Skip to content 99% OFF 🎉 Anniversary Sale 99% OFF Shared Hosting Use Code HURRYUP Claim Offer 99% OFF Hosting
99% OFF Hosting — Code HURRYUP
Products
AI Website Builder New VPS Hosting Cloud Servers Web Hosting cPanel Hosting Dedicated Servers Domains
Company
About Documentation Support Center Contact Get Started Call +91 75795 45488
Login
Hosting Panel — cPanel & Billing Console Panel — VPS Management
ALL SYSTEMS OPERATIONAL
VPS

Nginx Rate Limiting: Stop Bot Floods with limit_req

Getwebup 6 min read

If your VPS is getting hammered by bots, scrapers, or a brute-force script pounding your login form, the fix usually isn't a bigger server — it's telling Nginx to say no. The limit_req module has shipped with Nginx for years, does the job without a WAF or a CDN, and most people never turn it on until the day their box falls over during a scrape.

Symptom: What This Actually Looks Like

You check htop during a slow patch and see a wall of nginx worker processes pegged at 100%. tail -f /var/log/nginx/access.log shows the same handful of IPs (or a rotating botnet of them) hitting /wp-login.php, a search endpoint, or an API route dozens of times a second. Legitimate visitors start seeing timeouts or 502s because every worker is busy serving requests that were never going to convert into anything.

This is different from a DDoS at the network layer — you can still reach the server over SSH, load average is high but not maxed on I/O, and the traffic pattern is obviously automated: identical user agents, no referrer, requests spaced a fraction of a second apart.

Cause: Why Nginx Doesn't Block This by Default

A stock Nginx install has no concept of "too many requests from one client." Every request that reaches a server block gets processed on its merits, full stop. That's fine for normal traffic, but it means a single script with no rate limit of its own can consume as many worker connections as it wants — which is exactly what login-brute-forcers, content scrapers, and misconfigured monitoring tools do.

Cloudflare or a firewall like CSF can catch some of this upstream, but if you're running Nginx directly (no CDN in front, or traffic that bypasses it), the request still needs to be handled at the web server layer. That's what limit_req is for.

Fix: Setting Up limit_req Properly

The module works in two parts: you define a shared-memory zone that tracks request rates per key (usually the client IP), then you apply that zone to the locations you want protected.

1. Define the zone in the http block

Add this inside http { } in /etc/nginx/nginx.conf — not inside a server block, or it won't be shared across virtual hosts:

http {
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=login:10m rate=2r/m;
    limit_req_status 429;
    ...
}

$binary_remote_addr is used instead of $remote_addr because it stores the IP as a fixed-size binary value — roughly half the memory per entry, which matters once you're tracking tens of thousands of clients. A 10m zone holds around 160,000 IP addresses, plenty for a single VPS.

2. Apply the zone where it matters

Don't rate-limit your whole site with one aggressive rule — that punishes real visitors loading a page with a dozen assets at once. Scope it to the endpoints attackers actually hit:

server {
    location /wp-login.php {
        limit_req zone=login burst=3 nodelay;
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    location /wp-json/ {
        limit_req zone=general burst=20 nodelay;
        try_files $uri $uri/ /index.php?$args;
    }

    location / {
        limit_req zone=general burst=15;
        try_files $uri $uri/ /index.php?$args;
    }
}

burst lets a short spike through above the steady rate — a real user clicking around fast, or a page loading several sub-requests together — before Nginx starts rejecting anything. nodelay serves burst requests immediately instead of queuing them with artificial latency; without it, requests inside the burst window get held and slowed down rather than dropped, which is usually the wrong tradeoff for an API or login form.

3. Reload, don't restart

sudo nginx -t && sudo systemctl reload nginx

nginx -t catches typos in the zone syntax before they take your site down. A reload applies config changes without dropping active connections, unlike a full restart.

Testing It Actually Works

Don't wait for the next bot wave to find out. From your own machine (not the server itself, so you're testing the same path a real attacker would use):

for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code}\n" https://yourdomain.com/wp-login.php; done

You should see a run of 200s followed by 429s once you exceed the burst allowance. If everything comes back 200, the zone isn't being hit — check that the location block matching your test URL actually has the limit_req directive, and that you reloaded after editing the config.

Check /var/log/nginx/error.log too — Nginx logs a line like limiting requests, excess: 5.240 by zone "login" every time it rejects something, which is useful for confirming the rule is live before real traffic tests it.

Common Mistakes

MistakeWhat goes wrong
Setting one strict rate for the whole siteReal visitors get 429'd when a page loads CSS, JS, and images as separate requests in the same second
Forgetting nodelay on login or API endpointsLegitimate requests get artificially queued and feel slow instead of just being allowed or blocked
Rate-limiting by $remote_addr behind Cloudflare/a proxyEvery request appears to come from the proxy's IP, so real users share one bucket and get blocked together
No whitelist for your own monitoring or webhook IPsUptime checks and payment webhooks start failing with 429s, and it's rarely obvious why at first
Zone size too small for traffic volumeNginx starts evicting older IP entries, effectively resetting limits for returning bots

Whitelisting Traffic That Shouldn't Be Limited

If you're behind Cloudflare, install the ngx_http_realip_module config so $remote_addr reflects the visitor's real IP instead of Cloudflare's edge IP — otherwise every visitor shares the same rate-limit bucket:

set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... full Cloudflare IP range list
real_ip_header CF-Connecting-IP;

For known-good automated traffic — a payment gateway's webhook, an uptime monitor, your own CI pipeline — use a geo or map block to exempt specific IPs from the zone entirely rather than raising the limit for everyone:

geo $limit_exempt {
    default 0;
    203.0.113.10 1;  # your monitoring service
}
map $limit_exempt $limit_key {
    0 $binary_remote_addr;
    1 "";
}
limit_req_zone $limit_key zone=general:10m rate=10r/s;

An empty key means Nginx doesn't track that request against the zone at all.

Prevention: Keep It From Becoming a Fire Drill

  • Start with generous burst values and tighten gradually — watch the error log for a week before assuming a rule is too strict or too loose.
  • Pair limit_req with limit_conn (limits simultaneous connections per IP) if you're seeing slow, held-open connections rather than fast repeated requests.
  • Log 429s to a separate log file so a sudden spike shows up in monitoring without getting lost in normal access logs.
  • Review which IPs are getting limited monthly — persistent offenders are better handled with a firewall drop (CSF, ufw) than an Nginx rule, since that stops the connection before it even reaches a worker.

Rate limiting at the Nginx layer isn't a replacement for a WAF or CDN — it's the layer that catches everything those don't, especially on a VPS where you're the only thing standing between a script and your login form.

Frequently asked questions

Does limit_req replace the need for a firewall like CSF or ufw?

No. limit_req works at the web server layer and only affects HTTP requests reaching Nginx — it won't stop other protocols or reduce the load a flood puts on your network interface. Use it alongside a firewall, not instead of one; persistent offenders are better dropped at the firewall level.

Why am I seeing 429 errors for real visitors after adding limit_req?

This almost always means the burst value is too low for how many sub-requests a page load actually triggers, or you're rate-limiting by a shared proxy IP (like Cloudflare's) instead of the visitor's real IP. Add the realip module if you're behind a proxy, and raise burst before tightening the base rate.

What's the difference between limit_req and limit_conn?

limit_req caps how many requests per second a client can make — good against rapid-fire scraping or login attempts. limit_conn caps how many simultaneous open connections a client can hold — useful against slow, held-open connections like slowloris-style attacks. They're often used together.

Will limit_req slow down legitimate traffic if I set nodelay?

No — nodelay means requests within your burst allowance are served immediately, with no artificial delay. Without nodelay, Nginx queues burst requests and releases them at the steady rate, which adds latency. For login pages and APIs, nodelay is almost always what you want.

#nginx #rate-limiting #vps #limit_req #security #brute-force

Keep reading

Chat with Support