
A traffic spike doesn’t need to be a DDoS to take your VPS down. A few aggressive crawlers, brute-force login attempts, or a buggy client loop can saturate PHP-FPM workers. That can turn 200ms responses into 20-second timeouts.
This Nginx rate limiting tutorial shows how to add practical guardrails (per IP, per endpoint, and per “burst” pattern) without punishing real users.
The examples assume Ubuntu 24.04/25.04-style layouts with Nginx 1.24+. The same ideas apply on Debian 12/13, AlmaLinux 9/10, and Rocky Linux 9/10.
You’ll get copy-paste snippets, a curl-based testing workflow, and a rollout checklist you can follow under pressure.
If you want these protections with predictable CPU and memory headroom, start with a VPS where you control the web stack. A HostMyCode VPS is a solid baseline for Nginx + PHP-FPM, WordPress, and API backends that need traffic controls at the edge.
What you’ll build (and what you won’t)
- Per-IP request limits for sensitive paths (login, XML-RPC, search, admin-ajax, API endpoints).
- Connection limits to stop slow clients from hoarding sockets.
- Safe bursts so a normal user clicking around doesn’t get blocked.
- Clear signals in logs to prove the limits work before you ship them broadly.
We’ll keep it simple: built-in Nginx directives you can understand and maintain during an incident. No exotic modules, no heavyweight WAF stack.
Prerequisites and a quick baseline check
Before you rate-limit anything, confirm Nginx is where traffic terminates. Then confirm it sees the real client IP.
-
Confirm Nginx is running and run a config test:
sudo nginx -v sudo nginx -t -
Find the active site config (common paths):
- Ubuntu/Debian:
/etc/nginx/sites-available/and/etc/nginx/sites-enabled/ - Alma/Rocky:
/etc/nginx/nginx.confplus/etc/nginx/conf.d/*.conf
- Ubuntu/Debian:
-
If you’re behind a reverse proxy or load balancer, make sure Nginx logs the true client IP. Otherwise, every request appears to come from the proxy.
If this is wrong, your limits will throttle the whole site.
If you are using Nginx as a front proxy for another origin, see this Nginx reverse proxy tutorial for real IP handling and safe headers.
Nginx rate limiting tutorial: set up shared memory zones (the part people forget)
Nginx rate limiting relies on shared memory “zones” to track counters. Define zones once at the http {} level. Then reference them inside server {} or location {} blocks.
Create a dedicated snippet so you can reuse it across sites:
sudo nano /etc/nginx/conf.d/limit-zones.conf
Add the following (bump sizes on a busy multi-site server):
# /etc/nginx/conf.d/limit-zones.conf
# 10m typically tracks tens of thousands of distinct keys (IPs) depending on Nginx build.
limit_req_zone $binary_remote_addr zone=req_per_ip:20m rate=10r/s;
# Separate, tighter zone for sensitive endpoints like login.
limit_req_zone $binary_remote_addr zone=login_per_ip:10m rate=2r/s;
# Connection tracking (not requests). Use binary_remote_addr for efficiency.
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
Now validate:
sudo nginx -t
Why split zones? Browsing traffic and login attempts don’t look the same. Separate zones keep a noisy endpoint from polluting the counters you want for normal site traffic.
Apply safe, general limits at the server level
Start conservative. The goal is to blunt abusive spikes without making the site feel “throttled.”
After that, tighten only the endpoints that get hit.
Edit your site config (example uses a typical Ubuntu server block):
sudo nano /etc/nginx/sites-available/example.com
Inside server {}, add:
# Cap concurrent connections per IP (helps against slowloris-like behavior)
limit_conn conn_per_ip 20;
# Optional: return a simple status when conn limit triggers
limit_conn_status 429;
# General request limiting per IP with a burst.
# burst=40 allows short spikes (page loads pull multiple assets).
# nodelay means the burst is rejected immediately instead of queued.
limit_req zone=req_per_ip burst=40 nodelay;
limit_req_status 429;
Picking numbers: A real browser can trigger 10–30 requests in a second on a hard refresh. This is common on WordPress themes with lots of assets. Bots can do hundreds.
A 10r/s rate with burst=40 usually catches the latter without clipping the former.
If your audience is mobile-heavy or your pages are asset-heavy, start looser (for example 15r/s) and tighten later.
For APIs that need sustained throughput per client, keep per-IP limits higher. Then enforce stricter auth-based limits in the application.
Protect the endpoints that get hammered (WordPress and common apps)
Endpoint-specific limits are where rate limiting really pays off. You avoid slowing the whole site.
Instead, you put a gate on the few doors attackers keep kicking.
Add these location blocks inside the same server {}:
# WordPress login
location = /wp-login.php {
limit_req zone=login_per_ip burst=10 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
# WordPress XML-RPC is frequently abused. Consider disabling if you don't need it.
location = /xmlrpc.php {
limit_req zone=login_per_ip burst=5 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
# Admin AJAX can be chatty; keep it limited but not too tight.
location = /wp-admin/admin-ajax.php {
limit_req zone=req_per_ip burst=60 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
# A common hot path on content sites
location = /?s= {
limit_req zone=req_per_ip burst=30 nodelay;
}
Adjust the PHP socket to match your system (common: /run/php/php8.2-fpm.sock or /run/php/php8.3-fpm.sock). Check with:
ls -lah /run/php/
If your WordPress site suffers from cron spikes and background tasks, don’t “rate-limit it into correctness.” Fix the underlying scheduling problem first.
This guide on WordPress cron troubleshooting pairs well with endpoint limits.
Whitelist health checks, your office IP, and trusted bots (without opening a hole)
Whitelisting can turn into a bypass if you get sloppy. Keep it tight.
Only allow fixed admin IPs, your monitoring probes, and sources you can actually verify.
Use the geo directive in http {} to tag allowlisted IPs. Create:
sudo nano /etc/nginx/conf.d/allowlist.conf
# /etc/nginx/conf.d/allowlist.conf
geo $is_allowlisted {
default 0;
# Your office/static admin IP
203.0.113.10 1;
# Uptime monitoring probe IP (example)
198.51.100.25 1;
}
Now convert your limit logic into a map that only applies limits when not allowlisted:
sudo nano /etc/nginx/conf.d/limit-maps.conf
# /etc/nginx/conf.d/limit-maps.conf
map $is_allowlisted $limit_key {
1 "";
0 $binary_remote_addr;
}
# Create zones based on $limit_key instead of remote_addr.
# Empty key means "do not track/limit" for allowlisted clients.
limit_req_zone $limit_key zone=req_per_ip:20m rate=10r/s;
limit_req_zone $limit_key zone=login_per_ip:10m rate=2r/s;
limit_conn_zone $limit_key zone=conn_per_ip:10m;
Important: remove the earlier zone definitions from limit-zones.conf if you adopt this map approach. Each zone must be defined exactly once.
If you don’t have stable IPs, use safer access patterns instead of whitelisting random networks.
Start with SSH hardening, and keep admin panels off the public internet where you can.
Return a clean 429 page (so support tickets are readable)
When a user hits a limit, the response should be obvious in the browser. It should also be unmistakable in logs.
Add this inside your server {}:
error_page 429 = @rate_limited;
location @rate_limited {
add_header Retry-After 5 always;
add_header Content-Type text/plain;
return 429 "Too many requests. Please retry in a few seconds.\n";
}
Retry-After nudges well-behaved clients to back off. It also gives you a clean signal during troubleshooting.
Test your limits safely with curl (before you reload for the world)
Don’t tune by feel. Test the endpoints you touched, confirm you get 429s where expected, and verify normal pages still behave.
-
Reload Nginx after validation:
sudo nginx -t && sudo systemctl reload nginx -
Simulate a burst to a sensitive endpoint (expect some 429s):
for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-login.php; done -
Check your access log for 429s:
sudo tail -n 50 /var/log/nginx/access.log | grep " 429 " -
Check the error log messages Nginx emits (useful during tuning):
sudo tail -n 100 /var/log/nginx/error.log
If you’re missing early warning signals (spikes, mail queue pressure, repeated 429s), add basic log monitoring and alerting.
This guide to log monitoring on a hosting VPS shows a lightweight workflow that doesn’t require a full observability stack.
Common pitfalls (and quick fixes)
-
Everything is getting rate-limited behind Cloudflare or a load balancer.
Fix: configure real IP handling (real_ip_header,set_real_ip_from) so $remote_addr reflects the client. Don’t roll out limits until this is correct. -
Your API clients get 429 during normal use.
Fix: raise the per-IP rate, increase burst, or limit only the expensive endpoints. For authenticated APIs, consider limiting by token in-app rather than by IP. -
WordPress admin feels “randomly slow.”
Fix: don’t apply strictlimit_reqto/wp-admin/broadly. Target login, XML-RPC, and known hot paths like admin-ajax. -
Nginx won’t start after changes.
Fix: runnginx -tand read the exact line number. Most failures are a missing semicolon or placing a directive in the wrong context.
Hardening stack: rate limiting plus firewall rules (a practical combo)
Rate limiting cuts application load fast. A firewall reduces what the internet can even reach.
In practice, you want both.
If your server is directly on the internet, confirm you only expose what you use. That usually means SSH and HTTP/HTTPS (plus mail ports if you host mail). Everything else should be closed.
Use this as a companion checklist: firewall audit tutorial. If you prefer UFW specifically, follow UFW firewall setup.
On multi-tenant environments (reseller hosting, agency servers, lots of WordPress installs), these small controls help keep one attacked site from consuming the whole box.
Rollout checklist for production (VPS and dedicated servers)
- Confirm real client IP is correct in logs (especially behind proxies/CDNs).
- Start with server-level limits that are conservative (
10r/s+burst=40is a reasonable baseline). - Add endpoint limits for login and XML-RPC before tightening anything else.
- Implement a minimal allowlist (office IP + monitoring probes), not a wide net.
- Return
429withRetry-Afterso clients back off. - Test with
curl, then watch access/error logs for 24–48 hours. - Document the chosen numbers in the config file comments and in your ops notes.
Where this fits in a HostMyCode hosting setup
If you run WordPress or a PHP app on a self-managed server, Nginx rate limits are one of the quickest ways to keep performance stable during bot spikes.
Once you’ve tamed the traffic, focus on headroom. You want enough CPU/RAM to absorb peaks, plus an upgrade path that isn’t disruptive.
For predictable performance and root access, use HostMyCode VPS. If you don’t want to handle patching, security baselines, and web stack tuning yourself, managed VPS hosting is a better fit for production sites where downtime is expensive.
If you’re dealing with bot spikes, slow PHP during peak hours, or recurring 502/504 errors, you’ll get better results with a VPS that has real headroom and a stack you can manage quickly. Start on a HostMyCode VPS, or choose managed VPS hosting if you want help keeping Nginx, SSL, and security defaults consistent and up to date.
FAQ
Will Nginx rate limiting block Googlebot?
If your limits are reasonable, it usually won’t. Issues show up when you set very low rates (like 1r/s) across the entire site.
If you decide to allowlist, do it using verified IP ranges and keep the list maintained. Otherwise, you create an easy bypass.
Should I rate-limit by IP if many users share one NAT?
Be cautious on corporate networks, campuses, and some mobile carriers. Use higher bursts, and reserve the strict limits for abuse-prone endpoints (login, XML-RPC, password reset).
Is limit_req better than Fail2Ban?
They address different problems. limit_req reduces load immediately at the edge. Fail2Ban blocks repeat offenders over time.
On hosting servers, using both is common: rate-limit for instant stability, then ban persistent abusers.
What HTTP status should I use for throttling?
429 Too Many Requests is the right default. Add Retry-After so clients know when to retry.
How do I know the limits are too strict?
Watch for rising 429s on normal paths, support complaints from real users, or a drop in successful logins. Keep a simple log filter handy, for example: grep " 429 " /var/log/nginx/access.log | tail.