Brute-force login attempts, credential stuffing, and API scraping are routine threats for any public-facing server. Nginx's built-in rate limiting module stops these attacks at the edge — before PHP even starts. This guide covers the full directive set, burst configuration, exempting trusted IPs, protecting Drupal's login and JSON:API endpoints, and avoiding the most common misconfiguration that lets bursts overwhelm your server.
The Leaky Bucket Model
Nginx rate limiting uses the "leaky bucket" algorithm: requests fill a bucket at the client's rate; the bucket drains at a fixed rate you define. Requests that arrive when the bucket is full are either delayed or rejected. The burst parameter controls how many excess requests can be held in the bucket before Nginx starts returning 429s.
Core Directives
limit_req_zone
Defined in the http context. Creates a named shared memory zone that tracks state per key (typically client IP):
http {
# 10m zone holds ~160,000 IP states (64 bytes per state)
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=global:20m rate=100r/s;
}$binary_remote_addr— 4 bytes for IPv4, 16 bytes for IPv6; more compact than$remote_addrzone=name:size— shared memory zone name and size (10m = 10 MB)rate— acceptsr/s(per second) orr/m(per minute)
limit_req
Applied in server, location, or if context:
location /user/login {
limit_req zone=login burst=3 nodelay;
# ... fastcgi config
}burst=N— allow N requests to queue above the rate limitnodelay— serve burst requests immediately (no artificial delay); reject when burst is fulldelay=N— serve first N burst requests immediately, delay the rest until capacity frees
burst vs nodelay vs delay: The Critical Difference
This is where most configurations go wrong. Consider rate=1r/s burst=5:
| Configuration | Behaviour on 10 simultaneous requests |
|---|---|
burst=5 (no modifier) | First request served now; next 5 queued and served 1/second; last 4 rejected 503 |
burst=5 nodelay | First 6 requests served immediately; last 4 rejected 503. Token bucket still drains at 1/s |
burst=5 delay=2 | First 3 served immediately; next 3 queued; last 4 rejected 503 |
For login endpoints: use burst=3 nodelay — a human can type quickly but not 10 times per second.
For APIs: use burst=20 nodelay — allows legitimate bursty clients while capping sustained abuse.
Complete Drupal Configuration
http {
# ── Rate Limit Zones ─────────────────────────────────────────────────────
# Login: 5 attempts per minute per IP
limit_req_zone $binary_remote_addr zone=drupal_login:10m rate=5r/m;
# JSON:API / REST: 60 requests per second per IP (generous for APIs)
limit_req_zone $binary_remote_addr zone=drupal_api:10m rate=60r/s;
# General PHP: catch-all for anonymous traffic
limit_req_zone $binary_remote_addr zone=drupal_php:20m rate=20r/s;
# Per-server (not per-IP) to prevent server-wide floods
limit_req_zone $server_name zone=drupal_server:10m rate=500r/s;
# ── Status and Log Level ─────────────────────────────────────────────────
limit_req_status 429;
limit_req_log_level warn;
# ── Trusted IP Exemptions ─────────────────────────────────────────────────
# geo and map must be in the http context, not inside server {}
geo $limit {
default 1; # Apply rate limiting by default
127.0.0.1 0; # Localhost
10.0.0.0/8 0; # Internal network
192.168.1.50 0; # Office IP
}
# Map to empty string (no zone) for exempt IPs
map $limit $limit_key {
0 "";
1 $binary_remote_addr;
}
server {
listen 443 ssl;
server_name example.com;
root /var/www/html/web;
# ── Login Page ────────────────────────────────────────────────────────
location = /user/login {
limit_req zone=drupal_login burst=3 nodelay;
limit_req zone=drupal_server burst=50 nodelay;
# Return friendly error page on rate limit
error_page 429 /429.html;
try_files $uri /index.php?$query_string;
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}
# Password reset — even stricter
location = /user/password {
limit_req zone=drupal_login burst=1 nodelay;
try_files $uri /index.php?$query_string;
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}
# ── JSON:API ──────────────────────────────────────────────────────────
location ^~ /jsonapi/ {
limit_req zone=drupal_api burst=30 nodelay;
limit_req zone=drupal_server burst=200 nodelay;
try_files $uri /index.php?$query_string;
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}
# ── General PHP ───────────────────────────────────────────────────────
location ~ \.php$ {
limit_req zone=drupal_php burst=10 nodelay;
try_files $uri =404;
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Static assets — no rate limiting needed
location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|woff2|svg|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# 429 error page
location = /429.html {
internal;
return 429 '{"error":"Too many requests. Please slow down."}';
add_header Content-Type application/json;
}
}
}Using geo and map for Per-IP Exemptions
The geo approach above is the cleanest way to exempt IPs. An alternative using map with a variable key:
http {
# Set limit zone key to empty string for whitelisted IPs
geo $exempt_ip {
default 0;
127.0.0.1 1;
10.0.0.0/8 1;
}
map $exempt_ip $rate_limit_key {
1 ""; # Empty = no zone lookup = no limiting
0 $binary_remote_addr;
}
limit_req_zone $rate_limit_key zone=login:10m rate=5r/m;
}When $rate_limit_key is an empty string, Nginx skips the rate limit check entirely for that request.
Protecting xmlrpc.php and Other Attack Vectors
# Block common WordPress/Drupal attack endpoints outright
location = /xmlrpc.php {
return 444; # Close connection with no response
}
# Aggressive rate limit on user registration (if enabled)
location = /user/register {
limit_req zone=drupal_login burst=2 nodelay;
try_files $uri /index.php?$query_string;
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}
# Rate limit search (expensive Drupal operation)
location = /search/node {
limit_req zone=drupal_php burst=2 nodelay;
try_files $uri /index.php?$query_string;
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}Dry Run Mode: Test Before Enforcing
Nginx 1.17.1+ supports limit_req_dry_run: log rejections without actually blocking requests. Use this to tune limits on production traffic:
location /user/login {
limit_req zone=drupal_login burst=3 nodelay;
limit_req_dry_run on; # Log but don't actually reject
}Watch the log:
tail -f /var/log/nginx/error.log | grep "limiting requests"
# You'll see: [warn] limiting requests, excess: 0.800 by zone "drupal_login"Testing Rate Limits
# Send 20 requests in rapid succession and count 429 responses
for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/user/login
done
# Apache Bench — 50 requests, 10 concurrent
ab -n 50 -c 10 https://example.com/user/login
# wrk — 10 seconds, 10 threads, 100 connections
wrk -t10 -c100 -d10s https://example.com/jsonapi/node/articleMonitoring Rate Limit Events
# Count 429s in access log
awk '$9 == 429' /var/log/nginx/access.log | wc -l
# Top IPs being rate limited
awk '$9 == 429 {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
# Count rate limit log entries
grep "limiting requests" /var/log/nginx/error.log | wc -lSummary Checklist
- Define zones in the
httpblock withlimit_req_zone; apply withlimit_reqin specific locations - Use
$binary_remote_addrnot$remote_addr— saves 12 bytes per entry in the zone - Understand
nodelay: burst requests are served immediately but tokens are still consumed — it does not raise your effective rate - Set
limit_req_status 429(not the default 503) so clients can distinguish rate limiting from server errors - Use
geo+mapto exempt internal IPs and monitoring tools from rate limits - Apply strict limits on
/user/login,/user/password,/user/register— 5r/m is appropriate for login - Add a per-server zone in addition to per-IP zones to cap aggregate traffic from distributed attacks
- Test with
limit_req_dry_run onbefore enforcing; watch error logs for excess values - Set
fastcgi_read_timeoutlonger than your rate limit window so queued requests don't timeout - Monitor 429s in access logs weekly; adjust burst values based on real traffic patterns