DevOpsil
Nginx
93%
Needs Review

Nginx Rate Limiting in Production: Protect Your APIs

Riku TanakaRiku Tanaka6 min read

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:

ConfigurationBehavior
limit_req zone=api burst=0Hard limit — exceed rate = immediate 503
limit_req zone=api burst=10Queue up to 10 excess requests, delay them
limit_req zone=api burst=10 nodelayAllow 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 TypeSuggested RateBurstNotes
General API10-60 r/s2x rateAdjust based on actual usage
Login / Auth5 r/m per IP3Brute force protection
Password reset2 r/m per IP0Minimal or no burst
Registration3 r/m per IP2Prevents account farming
Search / Read60 r/s100Often safe to be generous
Write operations10 r/s20Protect backend writes
File upload5 r/m2Protect 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.

Share:

Was this article helpful?

Riku Tanaka
Riku Tanaka

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

More in Nginx

View all →

Discussion