๐ŸŽ New User? Get 20% off your first purchase with code NEWUSER20 ยท โšก Instant download ยท ๐Ÿ”’ Secure checkout Register Now โ†’
Menu

Categories

nginx Rate Limiting in Production: Config and Tuning Guide

nginx Rate Limiting in Production: Config and Tuning Guide
Your API endpoint gets hammered by a misbehaving mobile app that retries every failed request, or a scraper ignores your robots.txt and floods your server with 200 requests per second. Legitimate users start seeing 503 errors, your backend databases spike to 100% CPU, and you need nginx rate limiting configured yesterday. Rate limiting is your first line of defense against abuse, but misconfigured limits are worse than none: set them too low and you block real traffic during normal usage spikes; set them too high and they do nothing when you actually need protection. This guide walks through nginx rate limiting in production: the limit_req and limit_conn directives, zone memory sizing for tens of thousands of clients, burst and nodelay tuning to handle legitimate traffic spikes without queueing delays, per-endpoint limits for API protection, and logging to troubleshoot false positives. You will see real config snippets, memory calculations, and the exact log messages nginx writes when it rejects requests.

How nginx Rate Limiting Works

nginx rate limiting uses two pairs of directives. The limit_req_zone directive defines a shared memory zone that tracks request rates by a key (usually client IP), and limit_req applies that zone to a location. The limit_conn_zone and limit_conn pair works similarly but counts concurrent connections instead of request rate. Most production scenarios need limit_req for request throttling; limit_conn handles slow clients holding connections open.

The Leaky Bucket Algorithm

limit_req implements a leaky bucket: requests arrive and fill the bucket, which drains at the configured rate. If the bucket overflows, nginx rejects the request with a 503. Here is a basic configuration:
http {
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    server {
        location /api/ {
            limit_req zone=api;
            proxy_pass http://backend;
        }
    }
}
This creates a 10 MB zone named api that tracks each client IP ( $binary_remote_addr ) and allows 10 requests per second. When a client exceeds 10 r/s, nginx logs limiting requests, excess: 5.000 by zone "api" and returns 503. The excess value shows how many requests per second over the limit the client sent. Your access log shows the rejected request with status 503 and the error log records the reason.

Zones, Keys and Shared Memory

The zone is shared memory visible to all nginx worker processes. The key determines what you are limiting: $binary_remote_addr is the client IP in binary form (4 bytes for IPv4, 16 bytes for IPv6), which is more memory-efficient than the text form $remote_addr . Each unique key consumes about 128 bytes on x86_64 (the key itself plus tracking metadata). For $binary_remote_addr , 1 MB holds roughly 8,000 IPv4 addresses or 4,000 IPv6 addresses. limit_conn works the same way but counts active connections, not request rate:
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;

server {
    location /downloads/ {
        limit_conn conn_per_ip 5;
    }
}
This limits each IP to 5 concurrent connections to /downloads/ . Use limit_conn to prevent a single client from monopolizing worker connections, especially for slow downloads or long-polling endpoints. The ngx_http_limit_req_module documentation covers all parameters and edge cases.

Configuring limit_req Zones and Rates

limit_req_zone takes three parameters: the key variable, zone=name:size , and rate=Xr/s (or r/m for requests per minute). The key is almost always $binary_remote_addr unless you are behind a proxy, in which case you might use $http_x_forwarded_for , though that is trivially spoofed. For multi-tenant setups, $server_name or $http_host lets you limit per virtual host. Keys like $request_uri or $uri let you track specific paths, useful for protecting a single expensive endpoint.

Zone Memory Sizing

Calculate zone size based on expected unique clients per hour. Each IPv4 address consumes about 128 bytes in the zone (64 bytes for the entry, 64 bytes overhead). If you expect 50,000 unique IPs per hour, allocate 50,000 ร— 128 = 6.4 MB; round up to 8 MB for safety. IPv6 addresses need the same 128 bytes per entry despite the larger key size. When the zone fills, nginx evicts the least recently used entries, so size it to hold your peak hourly unique clients.
limit_req_zone $binary_remote_addr zone=api:8m rate=10r/s;
limit_req_zone $binary_remote_addr zone=static:4m rate=100r/s;
Here api is sized for 50k IPs, static for 25k. You can define multiple zones with different rates and apply them to different locations. If your server sits behind a load balancer or CDN, $binary_remote_addr will be the load balancer IP; use the realip module to extract the real client IP from X-Forwarded-For or X-Real-IP headers.

Choosing the Rate and Key

Rate depends on your application. For a read-heavy API, 10 r/s per IP is strict, 100 r/s is loose. For a login endpoint, 1 r/s with a burst (covered next) prevents brute force without blocking typos. For static assets served by nginx directly, 200-500 r/s is reasonable since nginx handles those with minimal overhead. Tune by watching your logs: if you see legitimate traffic getting 503s during normal usage, raise the rate or add burst.
limit_req_zone $binary_remote_addr zone=login:2m rate=1r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=50r/s;
limit_req_zone $binary_remote_addr zone=assets:5m rate=200r/s;

server {
    location /login {
        limit_req zone=login burst=5;
    }
    location /api/ {
        limit_req zone=api;
    }
    location /static/ {
        limit_req zone=assets;
    }
}
This applies stricter limits to authentication, moderate limits to API endpoints, and loose limits to static files. Each zone tracks the same key but enforces different rates.

Burst and nodelay: Handling Traffic Spikes

Without burst , any request over the rate limit is rejected immediately. A user refreshing a page or a client retrying a failed request can easily exceed 1 r/s for a moment. burst lets you queue a small number of excess requests instead of rejecting them outright. burst=20 allows up to 20 requests to queue; they are served at the configured rate, not immediately.

The burst Parameter

With rate=10r/s burst=20 , a client sending 30 requests in one second gets 10 served immediately (the rate), 20 queued (the burst), and 10 rejected. The queued 20 are served over the next 2 seconds at 10 r/s. This smooths out short spikes without blocking them. The client sees the first 10 responses instantly, then the next 20 trickle in, then 503s for the rest.
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

location /api/ {
    limit_req zone=api burst=20;
}
Your error log shows delaying request for queued requests and limiting requests, excess: X for rejected ones. The access log records delayed requests with normal 200 status but they took longer to complete. For API clients that can tolerate a bit of backpressure, queueing is fine. For web pages where a human is waiting, the delay is annoying.

nodelay vs Queueing

nodelay serves the burst immediately instead of queueing it. With rate=10r/s burst=20 nodelay , a client sending 30 requests gets 30 served instantly (10 from the rate, 20 from the burst), then the next request is delayed until the bucket refills. This is better for human users: reloading a page sends 5-10 requests for HTML, CSS, JS, images; queueing those creates a sluggish experience.
limit_req zone=api burst=20 nodelay;
The tradeoff: nodelay does not smooth traffic, it just prevents immediate rejection. A client can still send 30 r/s every second by using the burst, so the backend still sees spikes. For API endpoints where you want to enforce the rate strictly, omit nodelay . For user-facing pages, add it. The error log shows limiting requests, excess: X by zone "api", client: 192.0.2.1 for rejected requests but no delaying messages since the burst is served immediately.

limit_conn for Concurrent Connection Limits

limit_conn counts active connections per key, not request rate. A connection is active from the moment nginx accepts it until the response completes. Use limit_conn to prevent a single client from holding open too many connections, which can exhaust worker connections or backend pools. The classic use case is protecting against slowloris attacks, where a client opens hundreds of connections and sends partial requests slowly to tie up server resources.

When to Use limit_conn

limit_conn_zone defines the zone, limit_conn applies the limit. Here is a config that limits each IP to 10 concurrent connections:
http {
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

    server {
        location / {
            limit_conn conn_limit 10;
        }
    }
}
When a client opens an 11th connection, nginx rejects it with 503 and logs limiting connections by zone "conn_limit", client: 192.0.2.1 . For download endpoints or long-polling APIs, set a lower limit to prevent one client from monopolizing bandwidth. For example, limit downloads to 5 concurrent connections per IP:
location /downloads/ {
    limit_conn conn_limit 5;
    limit_rate 1m;  # also cap bandwidth per connection
}
This prevents a download manager from opening 50 parallel connections. The difference from limit_req : limit_req counts requests per second, limit_conn counts how many requests are in-flight right now. A client making 100 r/s with instant responses might never exceed limit_conn 10 , but would blow past limit_req zone=api rate=10r/s .

Combining limit_req and limit_conn

Apply both in the same location to limit rate and concurrency. Define both zones in the http context, then use both directives in the location:
http {
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

    server {
        location /api/ {
            limit_req zone=api burst=20 nodelay;
            limit_conn conn_limit 10;
            proxy_pass http://backend;
        }
    }
}
This enforces 10 r/s sustained rate and 10 concurrent connections per IP. A client that opens 15 connections gets 10 accepted and 5 rejected. A client sending 50 r/s gets 30 served (10 + burst) and 20 rejected. Order does not matter; nginx evaluates both checks before proxying to the backend. The ngx_http_limit_conn_module documentation details the connection counting behavior.

Rate Limiting by Endpoint and Condition

Different endpoints have different abuse profiles. A search endpoint that hits the database hard might need 1 r/s, while a cached read endpoint can handle 100 r/s. Static assets served by nginx directly can tolerate 500 r/s since they bypass the backend. Apply different limit_req directives to different locations to match each endpoint's capacity.

Per-Location Limits

Define multiple zones with different rates, then apply each zone to the appropriate location:
http {
    limit_req_zone $binary_remote_addr zone=search:5m rate=1r/s;
    limit_req_zone $binary_remote_addr zone=api:10m rate=50r/s;
    limit_req_zone $binary_remote_addr zone=static:5m rate=200r/s;

    server {
        location /api/search {
            limit_req zone=search burst=5;
            proxy_pass http://backend;
        }
        location /api/ {
            limit_req zone=api burst=20;
            proxy_pass http://backend;
        }
        location /static/ {
            limit_req zone=static;
            root /var/www;
        }
    }
}
The /api/search location gets the strictest limit because it is expensive. General API endpoints use a moderate limit, static files use a high limit. nginx matches the most specific location first, so /api/search matches before /api/ . No limit on /static/ is also an option; just omit limit_req entirely if you trust clients or have upstream CDN protection.

Conditional Rate Limiting with map

Use a map to set the rate limit key conditionally. For example, limit bots more aggressively than browsers:
http {
    map $http_user_agent $rate_limit_key {
        ~*(bot|crawler|spider) $binary_remote_addr;
        default '';
    }

    limit_req_zone $rate_limit_key zone=bots:10m rate=1r/s;

    server {
        location / {
            limit_req zone=bots burst=3;
            proxy_pass http://backend;
        }
    }
}
If the User-Agent contains "bot", "crawler", or "spider", $rate_limit_key is set to the client IP and the limit applies. Otherwise $rate_limit_key is empty, which does not match any entry in the zone, so the limit is not enforced. Only bots get throttled. To exempt internal IPs, use a geo block:
geo $internal_ip {
    default 1;
    10.0.0.0/8 0;
    192.168.0.0/16 0;
}

map $internal_ip $rate_limit_key {
    0 '';
    1 $binary_remote_addr;
}

limit_req_zone $rate_limit_key zone=external:10m rate=10r/s;
Internal IPs get $internal_ip=0 , so $rate_limit_key is empty and they bypass the limit. External IPs get limited. This is cleaner than duplicating location blocks with allow / deny rules.

Logging and Monitoring Rate Limit Events

When nginx rejects a request due to rate limiting, it logs to the error log at warn level by default. The message looks like this:
2025/01/15 14:23:45 [warn] 12345#12345: *67890 limiting requests, excess: 5.123 by zone "api", client: 192.0.2.1, server: example.com, request: "GET /api/users HTTP/1.1"
The excess value shows how many requests per second over the limit the client sent. If rate=10r/s and excess: 5.123 , the client sent 15.123 r/s. Queued requests show delaying request instead of limiting requests .

Error Log Entries

Adjust the log level with limit_req_log_level to reduce noise or increase visibility:
location /api/ {
    limit_req zone=api burst=20 nodelay;
    limit_req_log_level info;  # default is warn
}
Set it to info if you want rate limit messages visible but not alarming, or error if you only care about severe issues. Change the HTTP status code returned with limit_req_status :
limit_req_status 429;  # default is 503
Returning 429 (Too Many Requests) is semantically correct and helps clients distinguish rate limiting from server errors. Some monitoring tools alert on 5xx but ignore 4xx, so 429 reduces false alarms.

Custom Access Log for Rate Limits

The error log tells you when requests were rejected, but correlating with access logs shows the full picture. Add a custom log format that includes $limit_req_status :
log_format limited '$remote_addr - [$time_local] "$request" $status $limit_req_status';
access_log /var/log/nginx/limited.log limited;
$limit_req_status is PASSED if the request was allowed, DELAYED if queued, REJECTED if rejected, REJECTED_DRY_RUN in dry-run mode, or - if no limit applied. Grep for rejected requests:
grep REJECTED /var/log/nginx/limited.log | awk '{print $1}' | sort | uniq -c | sort -rn
This shows which IPs are getting rate limited most often. If you see a spike of REJECTED entries during normal traffic, your rate is too low. If you see no REJECTED entries during an attack, your rate is too high. For ongoing monitoring, export nginx logs to Prometheus using nginx-prometheus-exporter or parse the stub_status module output. The Nginx Configuration Mastery guide covers setting up structured logging and metrics collection for production environments.

Common Pitfalls and Tuning in Production

Most rate limiting failures in production come from three mistakes: zones too small to track all active clients, using $binary_remote_addr behind a load balancer, and burst values too low for typical page loads. Each one silently breaks protection or blocks legitimate users until you notice the pattern in logs.

Zone Exhaustion and Eviction

When a zone fills, nginx evicts the least recently used entries to make room for new ones. If you have 100,000 unique IPs per hour and a 1 MB zone (about 8,000 IPv4 addresses), active clients get evicted before their rate counters expire. An evicted client can immediately send another burst of requests as if they are new. Your rate limiting becomes ineffective against distributed attacks or high-traffic periods.

Check your access logs to estimate unique IPs per hour:

awk '{print $1}' /var/log/nginx/access.log | sort -u | wc -l

If you see 50,000 unique IPs and your zone is 2 MB, you are evicting active clients. Multiply unique IPs by 128 bytes and add 25% headroom. For 50k IPs: 50,000 ร— 128 = 6.4 MB, round up to 8 MB. For 200k IPs: 200,000 ร— 128 = 25.6 MB, allocate 32 MB. Large zones cost memory but eviction costs protection. nginx does not expose zone usage via stub_status, so monitor by tracking whether rejected request counts drop during high traffic (a sign of eviction) or by watching the excess values in error logs. If they are consistently high but few requests get rejected, clients are bypassing the limit.

False Positives Behind Load Balancers

$binary_remote_addr is the IP nginx sees, which is the load balancer IP if you are behind AWS ELB, Cloudflare, or any proxy. All clients appear to come from the same IP, so the rate limit applies to your entire user base combined. One legitimate user hitting 10 r/s blocks everyone else.

Use the ngx_http_realip_module to extract the real client IP from X-Forwarded-For or X-Real-IP headers. Configure set_real_ip_from with your load balancer or CDN IP ranges:

http {
    set_real_ip_from 10.0.0.0/8;       # internal load balancers
    set_real_ip_from 192.168.0.0/16;
    set_real_ip_from 2001:db8::/32;    # IPv6 if applicable
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
}

Now $binary_remote_addr is the real client IP, not the load balancer. real_ip_recursive on walks through the X-Forwarded-For chain to find the first untrusted IP. Only set set_real_ip_from for IPs you control; otherwise an attacker can spoof X-Forwarded-For to bypass limits. The ngx_http_realip_module documentation lists all directives and trust chain behavior.

The third pitfall: burst too low for parallel requests. A typical web page loads HTML, then requests CSS, JS, images in parallel (often 5-10 requests in under a second). With rate=10r/s burst=5, the page load hits the burst limit and subsequent assets get 503 errors. Users see broken styling or missing images. Tune burst to rate ร— typical page load time. If pages load in 2 seconds and your rate is 10 r/s, set burst=20. Before tuning:

limit_req zone=api burst=5;

A client loading a page with 15 assets gets 10 served (rate), 5 queued (burst), 10 rejected. After tuning:

limit_req zone=api burst=20 nodelay;

All 15 assets succeed immediately. The nodelay prevents queueing, which would make the page sluggish. Check your error log for limiting requests messages during normal traffic. If you see them, raise the burst.

Testing and Validating Rate Limits

Test rate limits in a staging environment with production-like traffic before deploying. Use curl in a loop to confirm limits work and measure how many requests succeed before rejection:

for i in {1..50}; do curl -s -o /dev/null -w '%{http_code}\n' https://example.com/api; done | sort | uniq -c

With rate=10r/s burst=20, you should see about 30 requests return 200, then the rest return 503 (or 429 if you set limit_req_status 429). The output looks like:

     30 200
     20 503

If you see more than 30 successful requests, your rate or burst is higher than expected. If you see fewer, check your zone size or whether clients are getting evicted. Use ab to simulate concurrent clients:

ab -n 100 -c 10 https://example.com/api

This sends 100 requests with 10 concurrent connections. The output shows Failed requests and Non-2xx responses; these should match your expected rejection count. For rate=10r/s burst=20, expect 70 failures out of 100 if all requests arrive instantly. Check the error log for confirmation:

grep 'limiting requests' /var/log/nginx/error.log | tail -20

You should see entries like limiting requests, excess: 7.000 by zone "api". The excess value confirms clients are exceeding the rate.

Test burst behavior by sending requests in quick succession. With nodelay, the first 30 requests (10 from rate, 20 from burst) succeed immediately, then nginx starts rejecting. Without nodelay, the first 10 succeed immediately, the next 20 queue and trickle out over 2 seconds, then rejections start. Run the curl loop and check response times:

for i in {1..50}; do curl -s -o /dev/null -w '%{time_total}\n' https://example.com/api; done

With nodelay, all successful responses come back in under 1 second. Without nodelay, you see increasing response times as requests queue. Test from multiple IPs to confirm per-IP isolation: use curl --interface eth0 or run tests from different hosts. If you see rate limits applying globally instead of per-IP, check your realip configuration. You might be tracking the load balancer IP instead of the client.

For realistic validation, replay production access logs in staging using tools like goreplay or tcpreplay. This shows how your limits behave under real traffic patterns, including legitimate bursts during peak hours. Watch for limiting requests messages that correlate with normal usage spikes, which indicate your rate or burst is too low.

Production Rate Limiting Checklist

Before enabling rate limiting in production, verify these points:

  • Zone size holds your peak hourly unique IPs with 25% headroom (unique IPs ร— 128 bytes ร— 1.25).
  • If behind a load balancer or CDN, set_real_ip_from and real_ip_header are configured to extract the real client IP.
  • burst is tuned to rate ร— typical page load time for user-facing endpoints, with nodelay to avoid queueing delays.
  • Different endpoints have appropriate rates: strict for authentication and expensive operations, moderate for APIs, loose for static assets.
  • limit_req_status 429 is set so clients can distinguish rate limiting from server errors.
  • Error log level is set to info or warn to capture rate limit events without flooding logs.
  • A custom access log format includes $limit_req_status for monitoring rejected requests.
  • Test in staging with realistic traffic, including parallel requests and multiple IPs, to confirm limits work without false positives.

For a complete nginx production setup including SSL/TLS, upstream configuration, and security hardening, see the AlmaLinux 9 + NGINX + PHP-FPM guide. It covers building a hardened web server from base OS through application deployment, with rate limiting as one layer of defense.

Frequently Asked Questions

What is the difference between limit_req and limit_conn in nginx?

limit_req limits the request rate (requests per second or per minute) using a leaky bucket algorithm, while limit_conn limits concurrent connections (how many requests are in-flight at the same time). Use limit_req for general API throttling and DDoS protection. Use limit_conn to prevent a single client from monopolizing connections, especially for slow downloads or long-polling endpoints. You can apply both to the same location.

How do I calculate the right zone size for nginx rate limiting?

Each unique key (typically an IP address) consumes about 128 bytes in the zone. Multiply your peak hourly unique IPs by 128 bytes, then add 25% headroom. For 50,000 unique IPs: 50,000 ร— 128 = 6.4 MB, round up to 8 MB. For 200,000 IPs, allocate 32 MB. Check your access logs with awk '{print $1}' access.log | sort -u | wc -l to estimate unique IPs. If the zone fills, nginx evicts least recently used entries and limits become ineffective.

Should I use burst with nodelay for API rate limiting?

For user-facing pages, yes. nodelay serves the burst immediately so CSS, JS and images load in parallel without queueing delays. For backend APIs where you want strict rate enforcement, omit nodelay so excess requests queue and drain at the configured rate, applying backpressure to the client. With nodelay, clients can still send bursts every second, so the backend sees spikes. Choose based on whether you prioritize user experience or strict rate enforcement.

How do I rate limit by IP address behind a load balancer or CDN?

Use the ngx_http_realip_module to extract the real client IP from X-Forwarded-For or X-Real-IP headers. Set set_real_ip_from to your load balancer or CDN IP ranges, and real_ip_header X-Forwarded-For. Enable real_ip_recursive on to walk the header chain. Only trust IPs you control; otherwise attackers can spoof headers to bypass limits. After configuration, $binary_remote_addr becomes the real client IP instead of the load balancer IP.

What HTTP status code does nginx return when rate limiting blocks a request?

nginx returns 503 Service Unavailable by default when a request exceeds the rate limit. Change this with limit_req_status 429 to return 429 Too Many Requests, which is semantically correct and helps clients and monitoring tools distinguish rate limiting from actual server errors. The error log records the rejection with limiting requests, excess: X.XXX by zone "name" showing how far over the limit the client was.

Share this article:
Julien Moreau
About the Author

Julien Moreau

Cybersecurity, Infrastructure Security, Risk Management, Technical Documentation

Julien Moreau is a cybersecurity specialist and technical author focused on protecting modern IT infrastructures through practical security controls and risk-aware system design.

He has extensive experience working with enterprise security environments, including system hardening, access control, threat mitigation, and...

Cybersecurity System Hardening Access Control Network Security Basics Security Monitoring

Stay Updated

Subscribe to our newsletter for the latest tutorials, tips, and exclusive offers.