Advanced Nginx Rate Limiting: Protect APIs, Login Endpoints, and Uploads on a VPS
Nginx’s built-in rate limiting is one of the most effective tools for protecting VPS applications — blocking brute force login attempts, preventing API abuse, and defending against HTTP floods before requests reach your application code. This guide goes beyond basic rate limiting to implement per-endpoint strategies, combined limits, custom error responses, and whitelist bypass for monitoring and trusted services.
Rate Limiting Concepts
<code"># Nginx uses the "leaky bucket" algorithm: # - Requests fill the bucket # - Bucket drains at the configured rate (e.g., 10r/s) # - When bucket overflows (burst exceeded), requests get 429 # Key parameters: # rate — sustained request rate allowed (e.g., 10r/s, 1r/m) # burst — number of requests to queue before rejecting # nodelay — process burst immediately (don't queue, don't delay) # delay — process first N burst requests immediately, delay the rest
Step 1: Define Rate Limit Zones (nginx.conf http block)
<code">sudo nano /etc/nginx/nginx.conf
<code">http {
# ── Zone definitions ─────────────────────────────────────
# Format: limit_req_zone KEY zone=NAME:MEMORY rate=RATE;
# KEY: what to rate-limit by ($binary_remote_addr = per IP, $http_authorization = per token)
# MEMORY: zone memory size (10m ≈ 160,000 IP addresses)
# General web traffic — 30 requests/second per IP
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
# Authentication endpoints — 5 attempts per minute per IP (login brute force)
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
# Password reset — 1 attempt per minute (prevent enumeration)
limit_req_zone $binary_remote_addr zone=password_reset:10m rate=1r/m;
# API endpoints — 100 requests/second per IP
limit_req_zone $binary_remote_addr zone=api:20m rate=100r/s;
# API with user-level limiting (rate limit per API key, not IP)
# Extracts the Bearer token from Authorization header
map $http_authorization $api_key {
default $binary_remote_addr; # Fall back to IP if no auth header
"~^Bearer (.+)$" $1; # Extract token value
}
limit_req_zone $api_key zone=api_by_key:20m rate=60r/m;
# File upload endpoint — 10 uploads/minute per IP
limit_req_zone $binary_remote_addr zone=upload:10m rate=10r/m;
# Search/expensive endpoint — 10 requests/minute per IP
limit_req_zone $binary_remote_addr zone=search:10m rate=10r/m;
# Webhook receiver — 100/second (external services may burst)
limit_req_zone $binary_remote_addr zone=webhook:10m rate=100r/s;
# Connection limit (simultaneous connections per IP, not rate)
limit_conn_zone $binary_remote_addr zone=per_ip:10m;
# Custom rate limit response
limit_req_status 429;
limit_conn_status 429;
}
Step 2: Per-Endpoint Configuration
<code">sudo nano /etc/nginx/sites-available/yoursite
<code">server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# ── General site-wide limits ─────────────────────────────
limit_req zone=general burst=100 nodelay;
limit_conn per_ip 50; # Max 50 simultaneous connections per IP
# ── Login endpoint ───────────────────────────────────────
location = /login {
# 5 attempts/minute, allow burst of 2 (handles double-click), no delay
limit_req zone=auth burst=2 nodelay;
proxy_pass http://127.0.0.1:3000;
}
# WordPress login (most targeted)
location = /wp-login.php {
limit_req zone=auth burst=1 nodelay;
# Also block xmlrpc.php which is used for brute force
proxy_pass http://127.0.0.1:8080;
}
# ── Password reset (strict — prevents user enumeration) ──
location = /api/auth/forgot-password {
limit_req zone=password_reset burst=1 nodelay;
proxy_pass http://127.0.0.1:3000;
}
# ── API endpoints ────────────────────────────────────────
location /api/ {
# Rate limit by API key (not IP — fairer for shared NATs)
limit_req zone=api_by_key burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
}
# ── Search endpoint (CPU-intensive) ──────────────────────
location /api/search {
limit_req zone=search burst=5 nodelay;
proxy_pass http://127.0.0.1:3000;
}
# ── File uploads ─────────────────────────────────────────
location /api/upload {
limit_req zone=upload burst=3 nodelay;
client_max_body_size 50M;
proxy_pass http://127.0.0.1:3000;
}
# ── Webhooks (allow bursts from trusted services) ─────────
location /webhooks/ {
limit_req zone=webhook burst=500 nodelay;
proxy_pass http://127.0.0.1:3000;
}
}
Step 3: Custom 429 Error Response
<code">sudo nano /etc/nginx/sites-available/yoursite
<code">server {
# Custom 429 response with Retry-After header
error_page 429 = @rate_limited;
location @rate_limited {
add_header Content-Type application/json always;
add_header Retry-After 60 always; # Tell client to retry in 60 seconds
add_header X-RateLimit-Limit 10 always; # What the limit is
return 429 '{"error":"rate_limited","message":"Too many requests. Please retry after 60 seconds.","retry_after":60}';
}
}
Step 4: Whitelist Trusted IPs
<code">sudo nano /etc/nginx/conf.d/rate-limit-bypass.conf
<code">## Bypass rate limiting for trusted IPs (monitoring, CI, office)
## This sets $limit = 0 for whitelisted IPs, bypassing limit_req
geo $limit {
default 1; # Default: apply rate limiting
127.0.0.1 0; # Localhost — bypass
10.0.0.0/8 0; # Internal network — bypass
203.0.113.10 0; # Office IP — bypass
203.0.113.50 0; # Monitoring server — bypass
}
## Then in the rate limit zone, multiply by $limit:
## limit_req_zone $binary_remote_addr$limit zone=api:20m rate=100r/s;
## When $limit = 0, the key becomes just "0" for all whitelisted IPs
## — they all share a very high rate bucket effectively bypassing limits
<code"># Alternative: use map to conditionally apply rate limiting:
map $binary_remote_addr $apply_rate_limit {
default $binary_remote_addr;
127.0.0.1 ""; # Empty string — bypasses limit_req_zone key
203.0.113.10 ""; # Office IP — bypass
}
limit_req_zone $apply_rate_limit zone=api:20m rate=100r/s;
# In server block:
limit_req zone=api burst=20 nodelay;
# When $apply_rate_limit is empty, no rate limiting applies
Step 5: Rate Limit Logging and Monitoring
<code"># Add rate limit info to Nginx log format: sudo nano /etc/nginx/nginx.conf
<code">log_format main_extended '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'$request_time $upstream_response_time '
'rl=$limit_req_status'; # r = rate limited, "" = passed
<code"># Monitor rate limiting in real-time:
tail -f /var/log/nginx/access.log | grep "429"
# Count 429s in the last hour:
grep "429" /var/log/nginx/access.log | \
awk '{print $1}' | sort | uniq -c | sort -rn | head -10
# Top rate-limited IPs:
awk '$9 == "429" {print $1}' /var/log/nginx/access.log | \
sort | uniq -c | sort -rn | head -10
Step 6: Graduated Response
<code"># Use multiple zones simultaneously for graduated limiting:
location /api/ {
# Apply both zones — stricter one wins:
limit_req zone=api burst=100 nodelay; # 100 req/s sustained
limit_req zone=api_by_key burst=60 nodelay; # 60/min per API key
# If either limit is exceeded → 429
proxy_pass http://127.0.0.1:3000;
}
Test Your Rate Limits
<code"># Test login rate limit (expect 429 after 5 requests/minute):
for i in $(seq 1 10); do
echo -n "Request $i: "
curl -s -o /dev/null -w "%{http_code}" \
-X POST https://yourdomain.com/login \
-d "email=test@test.com&password=wrong"
echo
sleep 0.1
done
# Expected: 200,200,200,200,200,429,429,429,429,429
# Test general rate limit (30 req/s — should handle 30 concurrent OK):
ab -n 100 -c 30 https://yourdomain.com/ 2>&1 | grep "Non-2xx"
Getting Started
Nginx rate limiting is built in — no additional packages required on any Ubuntu VPS at VPS.DO. Start by adding limit_req_zone definitions to your nginx.conf and applying limit_req to your most sensitive endpoints (login, password reset, API). Monitor the access log for 429 responses to tune burst values — too low causes false positives for legitimate users; too high provides insufficient protection.
Conclusion
Advanced Nginx rate limiting provides layered protection: general zones for overall traffic shaping, strict zones for authentication endpoints preventing brute force, API-key-based limits for fairer multi-user services, and IP whitelists for monitoring systems. Combined with Fail2ban (which bans IPs that trigger repeated 429s) and CrowdSec (which adds community threat intelligence), Nginx rate limiting is the first line of application-layer defense on any VPS. The custom 429 JSON response with Retry-After headers enables well-behaved clients to handle limits gracefully.