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)
  • burst allows 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.