Nginx Rate Limiting in Production: Protect Your APIs
Unprotected APIs are one breach or viral moment away from either a data leak or a bill shock. Nginx's built-in rate limiting is fast (handled in the request processing phase, before your app ever sees the request), configurable, and zero-cost. This guide goes beyond the basic examples and shows how to deploy rate limiting properly in production.
How Nginx Rate Limiting Works
Nginx uses a leaky bucket algorithm for limit_req. Requests arrive at any rate but are processed at a fixed rate — excess requests either wait in a queue or are rejected immediately.
There are two separate directives:
limit_req_zone/limit_req— limits requests per second from a key (usually IP address)limit_conn_zone/limit_conn— limits concurrent open connections from a key
You'll use both together for real protection.
Defining Rate Limit Zones
Zones are defined in the http block and referenced in server or location blocks:
# /etc/nginx/nginx.conf — http block
# General API rate limiting: 10 req/sec per IP
# Zone size: 10MB stores ~160,000 IP addresses
limit_req_zone $binary_remote_addr zone=api_general:10m rate=10r/s;
# Strict limit for authentication endpoints: 5 req/min per IP
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=5r/m;
# Very strict for password reset / OTP: 2 per minute
limit_req_zone $binary_remote_addr zone=api_sensitive:5m rate=2r/m;
# Connection limiting zone
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
# Per-server connection limit (total, not per IP)
limit_conn_zone $server_name zone=conn_per_server:10m;
$binary_remote_addr uses the binary representation of the IP — 4 bytes for IPv4, 16 for IPv6 — instead of the string form, saving memory in the zone.
Applying Limits to Location Blocks
server {
listen 443 ssl;
server_name api.example.com;
# General connection limits
limit_conn conn_per_ip 20;
limit_conn conn_per_server 1000;
# General API endpoints
location /api/ {
limit_req zone=api_general burst=20 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
# Authentication — much stricter
location /api/auth/login {
limit_req zone=api_auth burst=5 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
# Highly sensitive operations
location /api/auth/reset-password {
limit_req zone=api_sensitive burst=2 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
}
Understanding burst and nodelay
This is the part most tutorials gloss over:
| Configuration | Behavior |
|---|---|
limit_req zone=api burst=0 | Hard limit — exceed rate = immediate 503 |
limit_req zone=api burst=10 | Queue up to 10 excess requests, delay them |
limit_req zone=api burst=10 nodelay | Allow burst of 10 immediately, reject the rest with 429 |
nodelay is usually what you want for APIs — clients get their burst capacity without artificial delay, but anything beyond that is rejected immediately rather than queued and served slowly.
Handling Requests Behind a Load Balancer
If your Nginx sits behind a load balancer or CDN, $remote_addr is the load balancer's IP — all traffic appears to come from one source and hits limits instantly. Use the real client IP instead:
# In http block — trust your load balancer's IP range
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
# For Cloudflare, add their IP ranges:
# https://www.cloudflare.com/ips/
set_real_ip_from 103.21.244.0/22;
# ... (add all Cloudflare ranges)
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Then your rate limit zones will correctly key on $binary_remote_addr = real client IP.
Custom Error Pages for Rate Limiting
Return a proper JSON response instead of the default Nginx error page:
# Define custom error page content
map $status $rate_limit_body {
429 '{"error":"rate_limit_exceeded","message":"Too many requests. Please slow down.","retry_after":60}';
default '';
}
server {
# ...
# Intercept 429s and return JSON
error_page 429 @rate_limited;
location @rate_limited {
default_type application/json;
add_header Retry-After 60 always;
add_header X-RateLimit-Limit 10 always;
return 429 '{"error":"rate_limit_exceeded","message":"Too many requests","retry_after":60}';
}
}
Whitelisting Trusted IPs
Internal services, monitoring tools, and trusted partners shouldn't hit rate limits:
# In http block
geo $limit_key {
default $binary_remote_addr; # Apply limits by default
# Whitelist — these IPs bypass rate limiting (empty key = no limit)
10.0.0.0/8 "";
172.16.0.0/12 "";
192.168.0.0/16 "";
203.0.113.50 ""; # Trusted partner IP
}
# Use $limit_key in zone definition instead of $binary_remote_addr
limit_req_zone $limit_key zone=api_general:10m rate=10r/s;
When $limit_key is empty (for whitelisted IPs), Nginx skips the rate limit check entirely.
Rate Limiting by API Key
If your API uses key-based auth in a header, you can rate limit per key instead of per IP:
# Extract API key from header (or use $arg_api_key for query param)
limit_req_zone $http_x_api_key zone=api_by_key:20m rate=100r/s;
location /api/ {
# If API key is present, use key-based limits
# Otherwise fall back to IP-based
limit_req zone=api_by_key burst=200 nodelay;
proxy_pass http://backend;
}
This gives authenticated clients a much higher rate limit than anonymous users, which is the right behavior for most APIs.
Monitoring Rate Limit Effectiveness
Check if your limits are triggering too aggressively (or not enough):
# Watch for 429s in real time
tail -f /var/log/nginx/access.log | grep " 429 "
# Count 429s per minute over the last hour
awk '$9==429' /var/log/nginx/access.log | \
awk '{print $4}' | cut -d: -f1-3 | sort | uniq -c
# Top IPs hitting rate limits
awk '$9==429 {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
Log rate limit events separately for easier analysis:
# Separate log for rate-limited requests
log_format rate_limited '$remote_addr - $time_local "$request" '
'$status "$http_user_agent" "$http_referer"';
server {
# ...
location /api/ {
limit_req zone=api_general burst=20 nodelay;
# Log 429s to separate file
access_log /var/log/nginx/rate_limited.log rate_limited if=$rate_limited;
proxy_pass http://backend;
}
}
# Map to flag rate-limited requests
map $status $rate_limited {
429 1;
default 0;
}
Testing Your Rate Limits
Before deploying, verify limits work as configured:
# Test with Apache Bench — send 30 requests concurrently
ab -n 100 -c 30 https://api.example.com/api/users
# Test with hey (more modern)
hey -n 100 -c 30 https://api.example.com/api/users
# Watch for 429 responses in the output
# "Status code distribution: [429] 72 responses"
# Quick bash loop test
for i in $(seq 1 20); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://api.example.com/api/users)
echo "Request $i: $STATUS"
done
Production Rate Limit Reference
| Endpoint Type | Suggested Rate | Burst | Notes |
|---|---|---|---|
| General API | 10-60 r/s | 2x rate | Adjust based on actual usage |
| Login / Auth | 5 r/m per IP | 3 | Brute force protection |
| Password reset | 2 r/m per IP | 0 | Minimal or no burst |
| Registration | 3 r/m per IP | 2 | Prevents account farming |
| Search / Read | 60 r/s | 100 | Often safe to be generous |
| Write operations | 10 r/s | 20 | Protect backend writes |
| File upload | 5 r/m | 2 | Protect storage and processing |
Start conservative and loosen limits based on actual usage data from your access logs. It's easier to increase a limit than to explain to users why your service went down.
Was this article helpful?
SRE & Observability Engineer
If it's not measured, it doesn't exist. SLO-driven, metrics-obsessed, and the person who gets paged at 3 AM so you don't have to. Observability isn't optional.
Related Articles
Nginx Reverse Proxy & Caching: The Complete Guide
Set up Nginx as a reverse proxy with SSL termination, response caching, rate limiting, and load balancing for high-traffic applications.
Envoy Traffic Management: Retries, Timeouts, Canary Deployments, and Rate Limiting
Advanced Envoy traffic management — configuring retries with exponential backoff, per-request timeouts, weighted canary routing, global rate limiting, and fault injection for resilience testing.
HAProxy Advanced: SSL Termination, SNI Routing, and mTLS
Advanced HAProxy SSL configuration — multi-domain SNI routing from a single frontend, mutual TLS (mTLS) for service-to-service authentication, certificate management, and OCSP stapling.
Fix Nginx 502 Bad Gateway Behind a Reverse Proxy
Diagnose and fix Nginx 502 Bad Gateway errors when proxying to upstream backends — check sockets, timeouts, and upstream health.
Fix Nginx 'Too Many Open Files' Error
Resolve Nginx 'Too many open files' errors by increasing worker_rlimit_nofile, system ulimits, and kernel file descriptor limits.
Nginx Load Balancing: Round Robin, Least Conn, and IP Hash
Configure Nginx upstream load balancing with round robin, least connections, and IP hash strategies including health checks and failover.
More in Nginx
View all →Apache mod_proxy: Reverse Proxy, Load Balancing, and WebSocket Support
How to use Apache httpd as a reverse proxy with mod_proxy — proxying to backend services, load balancing across multiple upstreams, WebSocket proxying, and health check configuration.
Apache httpd: Virtual Hosts, SSL/TLS, and URL Rewriting in Production
How to configure Apache httpd for production use — name-based virtual hosts, SSL/TLS with Let's Encrypt, HTTP to HTTPS redirects, mod_rewrite rules, and performance tuning with MPM.
Envoy Proxy: Architecture, xDS Configuration, and Getting Started
An introduction to Envoy Proxy's architecture — listeners, clusters, filters, and the xDS dynamic configuration API. Covers static configuration for standalone use and how Envoy fits into service meshes like Istio.
HAProxy Configuration Guide: TCP/HTTP Load Balancing, Health Checks, and SSL
A comprehensive HAProxy configuration guide covering frontends, backends, balance algorithms, active health checks, SSL termination, ACLs, rate limiting, and the runtime socket API.