
Your VPS rarely goes down because “traffic is high.” It usually goes down because the traffic is useless. Think login probes, XML-RPC spam, hotlinkers, and API scraping that burns PHP workers and upstream sockets.
This Nginx rate limiting tutorial shows how to slow abusive clients in 2026 without punishing real customers, search crawlers, or your monitoring.
The examples assume Ubuntu 24.04 LTS or Debian 12/13, Nginx 1.24+ (or vendor builds), and common stacks. That includes WordPress, WooCommerce, and small APIs behind PHP-FPM or a reverse proxy.
You’ll roll limits out in stages, confirm behavior in logs, and keep a clean escape hatch for trusted IPs.
Before you touch Nginx: confirm the bottleneck is “bad requests”
Rate limiting helps when a small set of clients or endpoints drive a disproportionate request rate. Confirm that pattern first.
Usual suspects include /wp-login.php, /xmlrpc.php, /wp-json/, /api/, and /login.
- Quick top talkers (IP count):
sudo awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
- Quick top paths:
sudo awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
If you’re on cPanel/WHM with Nginx in front of Apache, you may be looking at two different log streams.
Nginx logs usually live under /etc/nginx/. Apache logs are often under /usr/local/apache/logs/.
Don’t assume. Confirm which layer is returning 200/403/429.
If the server is already wobbling, stabilize it before tightening request controls.
Disk I/O stalls can masquerade as a “traffic spike.”
Pair this with server disk I/O troubleshooting if you’re seeing high iowait or inconsistent response times.
Prerequisites and safe rollout plan
Roll rate limiting out in three passes. This helps you avoid blocking normal usage.
- Log-only observation (no blocking): add dedicated logs for the endpoints you suspect.
- Soft limits: return
429for obvious abuse (login/XML-RPC) while leaving the rest of the site untouched. - Broader limits: protect APIs and expensive routes only if you see sustained scraping.
Keep SSH protected, too. If you’re actively under attack, tighten firewall rules in parallel.
HostMyCode customers often combine Nginx controls with a host firewall. The UFW firewall configuration tutorial lays out a safe baseline.
If you want a clean VPS for these changes (or you’re separating multiple sites), a HostMyCode VPS gives you root access and predictable resources.
That matters when bot traffic spikes and you need to tune quickly.
Step 1 — Add the core limiting zones (http context)
Nginx rate limiting relies on shared-memory “zones.” These zones track request rates by a key.
The key is typically the client IP.
Define the zones inside the http {} context. You’ll usually do this in /etc/nginx/nginx.conf (or a file included from it).
Edit /etc/nginx/nginx.conf and add:
http {
# If you're behind Cloudflare/ELB, ensure you key on the real client IP.
# Only enable these if you have real_ip configured correctly.
# limit_req_zone $binary_remote_addr zone=perip:20m rate=10r/s;
# Per-IP general zone: good for APIs and broad scraping protection.
limit_req_zone $binary_remote_addr zone=perip:20m rate=5r/s;
# Login-specific zone: stricter, because legit users don't post credentials 20 times a second.
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
# Connection limiting helps when bots open many keep-alives.
limit_conn_zone $binary_remote_addr zone=connperip:10m;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
How big should zones be? In practice, 10m holds tens of thousands of entries. The exact number depends on key size and overhead.
For a single busy WordPress site, 10m–20m is usually enough. If Nginx logs that a “zone is full,” increase the size.
If you’re behind a proxy/CDN: don’t key on $binary_remote_addr until real_ip is correct.
Otherwise you’ll rate-limit the CDN’s edge IPs and throttle everyone.
Example for Cloudflare (adjust to your proxy ranges):
# /etc/nginx/conf.d/realip.conf
real_ip_header CF-Connecting-IP;
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ...add current published ranges...
real_ip_recursive on;
Validate first, then reload:
sudo nginx -t && sudo systemctl reload nginx
Step 2 — Apply limits to WordPress login and XML-RPC (highest ROI)
If you host WordPress, start with /wp-login.php and /xmlrpc.php.
These two paths absorb a huge share of automated abuse. They’re also expensive because they wake up PHP and often touch the database.
In your site’s server block (commonly /etc/nginx/sites-available/example.com), add:
server {
# ...existing config...
# Optional: dedicated log for auth endpoints
access_log /var/log/nginx/example.com.access.log;
location = /wp-login.php {
limit_req zone=login burst=5 nodelay;
limit_conn connperip 10;
# If you have a separate PHP location block, keep it consistent.
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location = /xmlrpc.php {
# Many sites can disable XML-RPC entirely. If you still need it, rate limit hard.
limit_req zone=login burst=2 nodelay;
limit_conn connperip 5;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
What do these settings do?
rate=1r/sin the zone means one request per second per IP on average.burst=5allows short spikes (double-submit clicks, password manager retries).nodelayrejects above-burst requests immediately (useful for brute force). Without it, Nginx queues and delays responses.limit_conncaps concurrent connections per IP to reduce socket hoarding.
Test from a single IP. Use staging or your own IP first:
for i in $(seq 1 15); do curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-login.php; done
You should see a few 200 (or 302) responses. Then you should see 429 once the burst is consumed.
Step 3 — Return a clean 429 page and log rate-limit events
Nginx can return a plain 503 for some limiting scenarios. It depends on how you’ve wired it.
For debugging—and to avoid confusing clients—return an explicit 429. Also make it easy to spot in logs.
Add this inside your server {}:
# Send a clear status for rate-limited requests
limit_req_status 429;
limit_conn_status 429;
error_page 429 = @rate_limited;
location @rate_limited {
add_header Retry-After 60 always;
add_header Cache-Control "no-store" always;
return 429 "Too Many Requests\n";
}
Next, make limit-triggered traffic easier to analyze.
In /etc/nginx/nginx.conf, define a log format that includes request time and upstream timing:
log_format timed '$remote_addr - $host "$request" $status '
'rt=$request_time urt=$upstream_response_time '
'ua="$http_user_agent"';
Use it for the site:
access_log /var/log/nginx/example.com.access.log timed;
At that point, a simple grep for 429 tells you who’s being throttled, on which path, and with which user agent.
Step 4 — Rate limit APIs and expensive endpoints (without breaking your app)
Once login abuse is under control, move to endpoints that are cheap to hit and expensive to serve.
Common examples are search, product filters, REST routes, and uncached pages that trigger heavy PHP work.
For the WordPress REST API (used by headless setups and the block editor), avoid blunt throttling.
A better pattern is to rate limit unauthenticated requests and leave logged-in traffic alone.
# Map a key that becomes empty for authenticated users (skips rate limiting)
map $http_cookie $wp_api_limit_key {
default $binary_remote_addr;
~*wordpress_logged_in_ "";
}
# Put this map in http{} (nginx.conf), then define a zone for it:
# limit_req_zone $wp_api_limit_key zone=wpapi:20m rate=3r/s;
Then in your server {}:
location ^~ /wp-json/ {
limit_req zone=wpapi burst=30;
# keep your usual routing
try_files $uri $uri/ /index.php?$args;
}
Why this works: for logged-in users (admin/editor), the key becomes empty. That means Nginx won’t apply the zone.
Anonymous scrapers still get limited.
If you run WooCommerce, watch for high-cardinality search and filter URLs that bypass cache.
Your access log timings will point at the first endpoints worth throttling.
Step 5 — Whitelist trusted IPs and health checks (so you don’t lock yourself out)
Two self-inflicted problems show up constantly.
You throttle your own office IP during a busy publish window. Or you throttle uptime monitors that check every 10–30 seconds.
Create a whitelist include file, for example:
sudo mkdir -p /etc/nginx/whitelists
sudo nano /etc/nginx/whitelists/trusted.conf
# /etc/nginx/whitelists/trusted.conf
# Your office / VPN / monitoring IPs
203.0.113.10 1;
203.0.113.11 1;
198.51.100.0/24 1;
Then in http {} add:
geo $is_trusted_ip {
default 0;
include /etc/nginx/whitelists/trusted.conf;
}
And skip limits for trusted clients using an if-safe pattern via a map (in http {}):
map $is_trusted_ip $limit_bypass {
0 1;
1 0;
}
Now apply in your locations:
location = /wp-login.php {
if ($limit_bypass) { set $apply_limit 1; }
if ($limit_bypass = 0) { set $apply_limit 0; }
# Only apply when $apply_limit=1 (use two locations to avoid if where possible)
}
Nginx doesn’t make conditional limit_req especially clean. It usually requires structuring your config around the constraint.
The simplest safe pattern is to split behavior. Use one path for trusted clients, and one for everyone else.
# Trusted clients: no throttle
location = /wp-login.php {
if ($is_trusted_ip) { break; }
# fall through to the throttled block via named location
return 418;
}
error_page 418 = @login_throttled;
location @login_throttled {
limit_req zone=login burst=5 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
If you hate the 418 trick, you can keep it explicit using satisfy any with allow/deny and an internal redirect.
The important part is the workflow. Implement, test, then reload.
Don’t improvise on production.
Step 6 — Add a basic bot shield for obvious bad user agents (optional)
Rate limiting controls volume. It does not judge intent.
For noisy scanners, a small user-agent denylist can cut background junk and reduce log spam.
Keep it conservative, since user agents are easy to spoof.
map $http_user_agent $bad_ua {
default 0;
~*(masscan|zgrab|sqlmap|nikto) 1;
}
server {
if ($bad_ua) { return 403; }
}
If you need deeper inspection (payload patterns, OWASP CRS rules), use a WAF instead of piling fragile regexes into Nginx.
HostMyCode covers that setup here: ModSecurity + OWASP CRS on Nginx.
Step 7 — Validate with real traffic, then tighten carefully
After you deploy, watch a few signals. They should reflect both user impact and server cost.
- 429 rate: it should climb during attack windows, not during normal business hours.
- Upstream response time: average
urtshould fall if bots were consuming PHP/Apache capacity. - PHP-FPM saturation: fewer max-children warnings and fewer queued requests.
Quick checks:
# Count rate-limited responses (last 10k lines)
tail -n 10000 /var/log/nginx/example.com.access.log | awk '$9==429{c++} END{print c+0}'
# See who is getting limited
awk '$9==429{print $1" "$7" "substr($0,index($0,"ua="))}' /var/log/nginx/example.com.access.log | head -n 30
If real users are getting clipped, increase burst first. Don’t raise rate first.
Burst absorbs human “clumps” (refreshes, flaky mobile retries) while still stopping sustained hammering.
Common pitfalls (and how to avoid them)
- Limiting CDN IPs instead of users: fix
real_ipfirst, then add limits. - Breaking WordPress admin editor: don’t aggressively throttle
/wp-json/for logged-in users. - 429 cached by intermediaries: add
Cache-Control: no-storeand a shortRetry-After. - Masking bigger issues: if disk I/O or memory pressure is the real culprit, limits only hide symptoms.
If you suspect compromised scripts or outbound spam, treat that as a separate incident.
For containment steps, follow VPS incident response triage before you spend time tuning traffic controls.
Practical checklist: production-ready Nginx throttling
- Define
limit_req_zoneandlimit_conn_zoneinhttp {}. - Verify correct client IPs (CDN/proxy real IP config).
- Apply strict limits to
/wp-login.phpand/xmlrpc.phpfirst. - Use
limit_req_status 429and a clear 429 handler. - Create dedicated access logs for troubleshooting.
- Whitelist your office/VPN/monitoring IPs.
- Reload Nginx, don’t restart:
nginx -t && systemctl reload nginx. - Review 429s after 24 hours and adjust burst/rate.
If bot spikes or login abuse are chewing through your resources, you’ll get better outcomes with full control over Nginx, firewall rules, and logging. Start with a HostMyCode VPS, or choose managed VPS hosting if you want help hardening and tuning without babysitting config files.
FAQ
Will Nginx rate limiting hurt SEO or block Googlebot?
If you limit only high-abuse endpoints (login, XML-RPC, and selected API routes), crawlers typically won’t notice.
Avoid blanket limits across every page unless you’ve confirmed scraping is the main problem.
If you must exempt certain clients, whitelist known monitoring and partner IPs. User-agent whitelisting alone isn’t reliable.
What’s a good starting limit for wp-login.php?
A common safe start is rate=1r/s with burst=5. That allows normal retries but shuts down brute force quickly.
If you have many users behind one NAT (schools, offices), increase burst first.
Should I block /xmlrpc.php instead of rate limiting it?
If you don’t use Jetpack, mobile app posting, or integrations that require XML-RPC, blocking is usually cleaner.
If you’re unsure, rate limit it for a week and watch access logs before you hard-block.
How do I confirm rate limiting is actually reducing load?
Compare before/after on request counts, upstream response time ($upstream_response_time), and PHP-FPM max-children warnings.
You should see fewer long-running upstream requests and lower CPU usage during attack windows.
Summary: keep the bad traffic cheap
Rate limiting works because it makes abusive traffic expensive for the attacker and cheap for your server.
Start with login and XML-RPC, return a clear 429, and expand to API routes only after you’ve confirmed real scraping pressure.
If you want a VPS that stays responsive under messy real-world traffic, run these controls on a HostMyCode VPS and keep your configuration versioned and tested.
That’s how “random bot noise” turns into a manageable background detail instead of a weekly outage.