Rate limiting is one of the most effective first lines of defense against DDoS attacks, brute force login attempts, and API abuse. Nginx includes two powerful built-in modules — limit_req and limit_conn — that let you throttle incoming traffic without installing third-party software. In this guide, you will configure both modules with production-ready examples, whitelist trusted IPs, and test everything before going live.
Prerequisites
- Nginx 1.18+ installed on a Linux server (Ubuntu/Debian or RHEL/CentOS)
- Root or sudo access
- Basic familiarity with Nginx server blocks and configuration syntax
- A tool for load testing:
ab(Apache Bench),wrk, orcurl
Understanding Nginx Rate Limiting
Nginx’s limit_req module implements the leaky bucket algorithm. Imagine a bucket with a small hole at the bottom: water (requests) pours in at varying rates, but drains out at a fixed rate. If the bucket overflows, excess water (requests) is rejected.
Two key concepts drive the configuration:
- Zone: A shared memory area that tracks request counts per key (usually client IP). Defined once in the
httpblock and referenced inserverorlocationblocks. - Rate: The allowed throughput expressed as requests per second (
r/s) or per minute (r/m). Nginx internally converts everything to per-second granularity.
The limit_conn module works differently — it caps the number of simultaneous open connections from a single key rather than the request rate.
Configuring Request Rate Limiting
Start by defining zones in the http block of /etc/nginx/nginx.conf:
http {
# General zone: 10 requests/second per IP, 10 MB shared memory
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
# Strict zone for login endpoints: 1 request/second per IP
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
# Return 429 instead of the default 503
limit_req_status 429;
}
The $binary_remote_addr variable stores client IPs in a compact binary format — 10 MB holds approximately 160,000 addresses.
Now apply these zones in your server block:
server {
listen 80;
server_name example.com;
# General rate limit for all requests
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://backend;
}
# Strict limit on login page
location /wp-login.php {
limit_req zone=login burst=3 nodelay;
proxy_pass http://backend;
}
# Custom 429 error page
error_page 429 /rate_limit.html;
location = /rate_limit.html {
internal;
return 429 '{"error": "Too many requests. Please retry after a few seconds."}';
default_type application/json;
}
}
The burst parameter defines how many excess requests can queue before Nginx starts rejecting. With nodelay, burst requests are processed immediately rather than being spaced out — this provides a better user experience for legitimate traffic spikes.
Configuring Connection Limits
Connection limiting prevents a single IP from holding too many sockets open simultaneously, which is the core mechanism behind slowloris-style attacks:
http {
limit_conn_zone $binary_remote_addr zone=addr:10m;
limit_conn_status 429;
}
server {
# Max 10 simultaneous connections per IP
limit_conn addr 10;
# Limit bandwidth per connection (optional)
limit_rate 100k;
location /downloads/ {
limit_conn addr 2;
limit_rate 50k;
}
}
Rate Limiting Comparison
| Feature | limit_req | limit_conn | External WAF (Cloudflare/ModSecurity) |
|---|---|---|---|
| Controls | Request rate (req/s) | Concurrent connections | Both + Layer 7 patterns |
| Algorithm | Leaky bucket | Connection counter | Varies (ML, signatures) |
| Granularity | Per key (IP, header, etc.) | Per key | Per rule, geo, ASN |
| Performance impact | Minimal | Minimal | Slight latency added |
| DDoS effectiveness | Good for L7 floods | Good for slowloris | Best for volumetric |
| Configuration | Nginx config only | Nginx config only | Separate service/agent |
| Cost | Free | Free | Free tier or paid |
For most servers, combining limit_req + limit_conn locally with an external WAF provides defense in depth.
Real-World Scenario
You have a production server running WordPress behind Nginx. Your access logs show thousands of POST requests hitting /wp-login.php and /xmlrpc.php from rotating IPs — a distributed brute force attack. CPU usage is spiking and legitimate users are seeing timeouts.
Here is a production configuration that addresses this:
http {
limit_req_zone $binary_remote_addr zone=wp_login:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=xmlrpc:10m rate=1r/m;
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_status 429;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
}
server {
listen 443 ssl;
server_name example.com;
limit_conn conn_limit 15;
location / {
limit_req zone=general burst=50 nodelay;
proxy_pass http://127.0.0.1:8080;
}
location = /wp-login.php {
limit_req zone=wp_login burst=3 nodelay;
proxy_pass http://127.0.0.1:8080;
}
# Block xmlrpc.php almost entirely — 1 request per minute
location = /xmlrpc.php {
limit_req zone=xmlrpc burst=1 nodelay;
proxy_pass http://127.0.0.1:8080;
}
}
After applying this configuration and running nginx -t && systemctl reload nginx, the brute force traffic gets 429 responses while legitimate users continue authenticating normally.
Whitelisting Trusted IPs
Monitoring systems, health checks, and office networks should bypass rate limits. Use the geo module to create a whitelist:
http {
geo $rate_limit_key {
default $binary_remote_addr;
10.0.0.0/8 ""; # Internal network
192.168.1.0/24 ""; # Office network
203.0.113.50 ""; # Monitoring server
}
# Empty key = no tracking = no rate limit
limit_req_zone $rate_limit_key zone=general:10m rate=10r/s;
}
When $rate_limit_key resolves to an empty string, Nginx skips rate limit tracking entirely for that request.
Testing Rate Limits
Always test in staging before deploying to production. Use limit_req_dry_run on to log violations without blocking:
location / {
limit_req zone=general burst=20 nodelay;
limit_req_dry_run on; # Log only, don't block
}
Then generate traffic with Apache Bench:
# Send 100 requests with 10 concurrent connections
ab -n 100 -c 10 https://staging.example.com/
# Check how many were rate limited
grep "limiting requests" /var/log/nginx/error.log | wc -l
# Check 429 responses in access log
awk '$9 == 429' /var/log/nginx/access.log | wc -l
Or use wrk for sustained load testing:
wrk -t4 -c50 -d30s https://staging.example.com/
Once you are satisfied with the thresholds, remove the limit_req_dry_run directive and reload Nginx.
Gotchas and Edge Cases
- Clients behind CDN/proxy: If Nginx sits behind Cloudflare, a load balancer, or a reverse proxy,
$binary_remote_addrwill be the proxy IP — not the client. Useset_real_ip_fromandreal_ip_headerto extract the actual client IP:
set_real_ip_from 173.245.48.0/20; # Cloudflare IP range
set_real_ip_from 103.21.244.0/22;
real_ip_header CF-Connecting-IP;
- Shared IPs (NAT/corporate): Aggressive rate limits can block entire offices behind a single NAT IP. Set reasonable burst values and whitelist known corporate ranges.
- Zone memory exhaustion: If the zone runs out of memory, Nginx returns 503 for all new keys. Monitor zone usage and size accordingly — 1 MB holds roughly 16,000 IP addresses.
- Rate vs burst misunderstanding: A rate of
10r/swithburst=20does not allow 30 requests/second. It allows a burst queue of 20 requests that drain at 10/s. Withoutnodelay, queued requests experience artificial delay. - Log noise: High-traffic rate limiting fills error logs quickly. Use a separate
error_logfor rate-limited locations or adjust log levels.
Summary
- Use
limit_reqto throttle request rates with the leaky bucket algorithm — ideal for login pages, APIs, and general flood protection. - Use
limit_connto cap concurrent connections per IP — effective against slowloris and resource exhaustion attacks. - Always change the default status code to 429 with
limit_req_status 429. - Whitelist internal IPs using the
geomodule so monitoring and health checks are never blocked. - Test with
limit_req_dry_run onand tools likeaborwrkbefore enforcing in production. - Account for proxies and CDNs by configuring
real_ip_headerto identify the actual client IP. - Combine Nginx rate limiting with Fail2ban and an external WAF for defense in depth.