API Rate Limiting with Nginx and Redis
Rate limiting protects your API from abuse, brute force attacks, and resource exhaustion by controlling how many requests a client can make in a given time period. This guide covers implementing rate limiting with Nginx's built-in limit_req module, Redis-based distributed rate limiting with OpenResty and Lua, and per-client quota management for production APIs.
Prerequisites
- Ubuntu 20.04+ or CentOS/Rocky 8+ with root access
- Nginx installed and running
- Redis (for distributed rate limiting)
- Optional: OpenResty for Lua-based rate limiting
Nginx limit_req Module Basics
Nginx includes the ngx_http_limit_req_module by default:
# Verify the module is compiled in
nginx -V 2>&1 | grep limit_req
# Check module is loaded
nginx -T | grep limit_req
The leaky bucket algorithm in limit_req:
- Requests arrive at variable rates
- Processing occurs at a fixed rate (the limit)
burstallows short bursts above the rate- Excess requests are either delayed or rejected (with
nodelay)
Rate Limiting by IP and User
Add rate limiting zones to your Nginx config:
sudo nano /etc/nginx/nginx.conf
Inside the http {} block:
http {
# Define rate limit zones in memory
# Zone format: name, key, memory_size
# Per-IP rate limiting (10MB shared memory, 100 requests/minute)
limit_req_zone $binary_remote_addr zone=api_by_ip:10m rate=100r/m;
# Per-IP aggressive limit for auth endpoints
limit_req_zone $binary_remote_addr zone=login_by_ip:10m rate=5r/m;
# Per-API-key rate limiting (using custom header)
limit_req_zone $http_x_api_key zone=api_by_key:10m rate=1000r/m;
# Global rate limiting (all requests)
limit_req_zone $server_name zone=global:10m rate=10000r/m;
# Custom response for rate-limited requests
limit_req_status 429;
limit_conn_status 429;
}
Apply rate limits to specific locations:
sudo nano /etc/nginx/sites-available/myapi
server {
listen 443 ssl http2;
server_name api.example.com;
# Global rate limit on all endpoints
limit_req zone=api_by_ip burst=20 nodelay;
# General API endpoints
location /api/ {
limit_req zone=api_by_ip burst=30 nodelay;
limit_req_log_level warn; # Log at warn level when limiting
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Real-IP $remote_addr;
}
# Strict limit on authentication endpoints
location /api/auth/login {
limit_req zone=login_by_ip burst=3 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location /api/auth/register {
limit_req zone=login_by_ip burst=2 nodelay;
proxy_pass http://127.0.0.1:3000;
}
# Higher limit for API-key authenticated requests
location /api/v2/ {
# Apply both limits - the stricter one applies
limit_req zone=api_by_key burst=100 nodelay;
limit_req zone=api_by_ip burst=50 nodelay;
proxy_pass http://127.0.0.1:3000;
}
}
sudo nginx -t && sudo systemctl reload nginx
Rate Limiting by IP and User
Rate limit by authenticated user (using JWT sub claim from upstream):
# Rate limit based on value set by upstream app
# Your backend sets X-User-ID header
map $upstream_http_x_user_id $rate_limit_key {
default $binary_remote_addr; # Fallback to IP if no user ID
~.+ $upstream_http_x_user_id; # Use user ID when present
}
limit_req_zone $rate_limit_key zone=per_user:20m rate=1000r/h;
Advanced Nginx Rate Limiting
Whitelist trusted IPs from rate limiting:
# Define trusted IPs (e.g., your office, monitoring servers)
geo $limit {
default 1; # Rate limit by default
10.0.0.0/8 0; # Internal network - no limit
192.168.1.100 0; # Specific IP - no limit
203.0.113.50 0; # Monitoring server
}
# Use geo variable as rate limit key
# When $limit=0, the empty string means no limit applied
map $limit $limit_key {
0 "";
1 $binary_remote_addr;
}
limit_req_zone $limit_key zone=api_limited:10m rate=100r/m;
Different limits by request method:
# Stricter limits for write operations
map $request_method $write_methods {
POST "post_$binary_remote_addr";
PUT "post_$binary_remote_addr";
DELETE "post_$binary_remote_addr";
PATCH "post_$binary_remote_addr";
default "";
}
limit_req_zone $write_methods zone=api_writes:10m rate=20r/m;
location /api/ {
# Apply write limit only for mutating methods
limit_req zone=api_writes burst=5 nodelay;
proxy_pass http://127.0.0.1:3000;
}
Installing OpenResty for Lua Scripting
OpenResty is an Nginx-based platform with embedded Lua support:
# Ubuntu
wget -O - https://openresty.org/package/pubkey.gpg | sudo apt-key add -
echo "deb http://openresty.org/package/ubuntu $(lsb_release -sc) main" | \
sudo tee /etc/apt/sources.list.d/openresty.list
sudo apt update && sudo apt install -y openresty
# CentOS/Rocky
sudo yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo
sudo dnf install -y openresty
# Install Redis client for Lua
sudo opm get openresty/lua-resty-redis
# Start OpenResty
sudo systemctl enable --now openresty
Redis-Based Distributed Rate Limiting
Redis-based rate limiting works across multiple Nginx instances:
# Install Redis
sudo apt install redis-server # Ubuntu
sudo systemctl enable --now redis
Create the Lua rate limiting script:
sudo mkdir -p /etc/openresty/lua
sudo tee /etc/openresty/lua/rate_limit.lua << 'EOF'
local redis = require "resty.redis"
local red = redis:new()
-- Configuration
local REDIS_HOST = "127.0.0.1"
local REDIS_PORT = 6379
local REDIS_TIMEOUT = 1000 -- 1 second timeout
-- Connect to Redis
red:set_timeout(REDIS_TIMEOUT)
local ok, err = red:connect(REDIS_HOST, REDIS_PORT)
if not ok then
ngx.log(ngx.ERR, "Failed to connect to Redis: ", err)
-- Fail open: allow request if Redis is unavailable
return
end
-- Get client identifier
local client_ip = ngx.var.binary_remote_addr
local api_key = ngx.req.get_headers()["X-API-Key"]
local rate_key = api_key and ("apikey:" .. api_key) or ("ip:" .. client_ip)
-- Rate limit configuration
local limit = tonumber(ngx.var.rate_limit_requests) or 100
local window = tonumber(ngx.var.rate_limit_window) or 60 -- seconds
-- Sliding window rate limiting using Redis sorted set
local now = ngx.now() * 1000 -- milliseconds
local window_start = now - (window * 1000)
-- Remove old entries
red:zremrangebyscore(rate_key, 0, window_start)
-- Count current requests in window
local count, err = red:zcard(rate_key)
if err then
ngx.log(ngx.WARN, "Redis error: ", err)
red:set_keepalive(10000, 100)
return
end
count = tonumber(count) or 0
-- Set rate limit headers
ngx.header["X-RateLimit-Limit"] = limit
ngx.header["X-RateLimit-Remaining"] = math.max(0, limit - count - 1)
ngx.header["X-RateLimit-Reset"] = math.ceil(now / 1000) + window
if count >= limit then
-- Rate limit exceeded
ngx.header["Retry-After"] = window
ngx.status = 429
ngx.say('{"error": "rate_limit_exceeded", "message": "Too Many Requests"}')
ngx.exit(429)
end
-- Record this request
red:zadd(rate_key, now, now)
red:expire(rate_key, window + 1)
-- Return connection to pool
red:set_keepalive(10000, 100)
EOF
Configure OpenResty to use the Lua script:
sudo tee /etc/openresty/conf.d/api-rate-limit.conf << 'EOF'
upstream api_backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 8080;
server_name api.example.com;
# Default limits (can be overridden per location)
set $rate_limit_requests 100;
set $rate_limit_window 60;
location /api/ {
# Run rate limiter before proxying
access_by_lua_file /etc/openresty/lua/rate_limit.lua;
proxy_pass http://api_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Stricter limit for auth
location /api/auth/ {
set $rate_limit_requests 10;
set $rate_limit_window 60;
access_by_lua_file /etc/openresty/lua/rate_limit.lua;
proxy_pass http://api_backend;
}
}
EOF
Per-Client Quota Management
Manage API keys with different quotas stored in Redis:
sudo tee /etc/openresty/lua/api_key_quota.lua << 'EOF'
local redis = require "resty.redis"
local cjson = require "cjson"
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "Redis connection failed: ", err)
ngx.exit(500)
end
local api_key = ngx.req.get_headers()["X-API-Key"]
if not api_key then
ngx.status = 401
ngx.say('{"error": "missing_api_key"}')
ngx.exit(401)
end
-- Look up API key configuration
-- Format: HSET apikey:YOUR_KEY limit 1000 window 3600 tier premium
local key_info, err = red:hgetall("apikey:" .. api_key)
if not key_info or #key_info == 0 then
ngx.status = 401
ngx.say('{"error": "invalid_api_key"}')
ngx.exit(401)
end
-- Convert HGETALL array to table
local info = {}
for i = 1, #key_info, 2 do
info[key_info[i]] = key_info[i+1]
end
local limit = tonumber(info["limit"]) or 100
local window = tonumber(info["window"]) or 3600
local tier = info["tier"] or "free"
-- Check quota
local usage_key = "usage:" .. api_key .. ":" .. math.floor(ngx.now() / window)
local current, err = red:incr(usage_key)
if err then
ngx.log(ngx.ERR, "Redis INCR error: ", err)
end
-- Set TTL on first request in window
if tonumber(current) == 1 then
red:expire(usage_key, window + 10)
end
-- Set headers
ngx.header["X-API-Tier"] = tier
ngx.header["X-RateLimit-Limit"] = limit
ngx.header["X-RateLimit-Remaining"] = math.max(0, limit - tonumber(current))
ngx.header["X-RateLimit-Reset"] = math.ceil(ngx.now() / window) * window
if tonumber(current) > limit then
ngx.status = 429
ngx.header["Retry-After"] = window - (ngx.now() % window)
ngx.say(cjson.encode({
error = "quota_exceeded",
tier = tier,
limit = limit,
window_seconds = window,
}))
ngx.exit(429)
end
red:set_keepalive(10000, 100)
EOF
Set up API keys in Redis:
# Create API keys with different tiers
redis-cli HSET "apikey:free-key-abc123" limit 100 window 3600 tier free
redis-cli HSET "apikey:pro-key-xyz789" limit 10000 window 3600 tier pro
redis-cli HSET "apikey:enterprise-key-qrs456" limit 100000 window 3600 tier enterprise
Rate Limit Response Headers
Return informative headers in Nginx rate limiting (using map):
# Add to server block
add_header X-RateLimit-Limit "100" always;
add_header X-RateLimit-Policy "100;w=60" always;
# For the 429 response, add Retry-After
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
# Custom 429 error page
error_page 429 @ratelimited;
location @ratelimited {
add_header Content-Type "application/json" always;
add_header Retry-After 60 always;
return 429 '{"error": "rate_limit_exceeded", "message": "Too Many Requests. Try again in 60 seconds."}';
}
Troubleshooting
Legitimate users getting rate limited:
# Check which zone is triggering
sudo tail -100 /var/log/nginx/error.log | grep "limiting requests"
# Output: limiting requests, excess: 0.772 by zone "api_by_ip"
# Increase burst allowance
# limit_req zone=api_by_ip burst=50 nodelay;
# Check the effective rate
# "100r/m" = ~1.67 req/sec sustained
Redis connection failures in Lua:
# Test Redis connectivity
redis-cli ping
# Check Redis bind address
grep "^bind" /etc/redis/redis.conf
# Ensure not bound to 127.0.0.1 only if OpenResty is on different host
Rate limiting not resetting:
# Check Redis keys
redis-cli keys "ip:*" | head -10
redis-cli ttl "ip:192.168.1.1"
# TTL should decrease; if not, EXPIRE wasn't set
"no live upstreams while connecting to upstream" during rate limiting:
# This is a different error - check backend health
curl -s http://127.0.0.1:3000/health
sudo systemctl status myapp
Conclusion
Nginx's built-in limit_req module handles most rate limiting needs without additional software, using the leaky bucket algorithm with configurable burst allowances. For multi-server deployments or per-client quota management, OpenResty with Redis provides a distributed rate limiter using the sliding window algorithm. Always return X-RateLimit-* headers so clients can adapt their request rates, and whitelist monitoring systems and internal services from rate limiting to avoid false alerts.


