Nginx Rate Limiting: How to Stop Abuse Without Breaking Legitimate Traffic

Rate limiting is one of those things that’s easy to get wrong in both directions — too loose and it does nothing, too tight and you start blocking real users. I’ve tuned rate limiting configs on production sites a few times and I’ve made both mistakes.

Here’s what I’ve learned about how Nginx rate limiting actually works and how to configure it sensibly.

How Nginx Rate Limiting Works

Nginx uses a leaky bucket algorithm. Think of it as a bucket with a hole — requests flow in from the top, and get processed at a fixed rate through the hole at the bottom. If requests come in faster than they’re processed, they overflow and get rejected.

There are two main directives: limit_req_zone (defines the zone) and limit_req (applies the limit).

http {
    # Define a zone: use client IP, 10MB memory, 10 requests/second
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    server {
        location /api/ {
            limit_req zone=api;
        }
    }
}

$binary_remote_addr — the client IP in binary form (4 bytes for IPv4, saves memory vs. string).
zone=api:10m — name the zone “api”, allocate 10MB of shared memory (stores ~160,000 IPs).
rate=10r/s — allow 10 requests per second per IP.

The Burst Parameter

Without burst, any IP that exceeds the rate limit gets an immediate 503. That’s pretty harsh — a user opening a page that fires 3 simultaneous XHR requests would hit the limit.

The burst parameter adds a queue:

limit_req zone=api burst=20;

Now, up to 20 excess requests queue up and get processed as soon as the rate allows, rather than being rejected immediately. This smooths out legitimate traffic spikes.

But queuing has a cost: requests sit there waiting. A burst of 20 at 10r/s means requests could wait up to 2 seconds in the queue. That’s noticeable.

The nodelay Option

Add nodelay to process burst requests immediately (up to the burst limit) without waiting:

limit_req zone=api burst=20 nodelay;

With nodelay, up to 20 simultaneous requests are processed immediately. The 503 only happens when the burst queue overflows. This is usually what you want for APIs — don’t make users wait, just reject genuine excess.

Practical Configuration

Here’s a config I use for a typical application:

http {
    # Strict limit for login endpoints (anti-brute-force)
    limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

    # General API rate limit
    limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;

    # General web pages
    limit_req_zone $binary_remote_addr zone=web:10m rate=50r/s;

    server {
        # Login endpoint: 5 attempts per minute, small burst
        location /api/login {
            limit_req zone=login burst=5 nodelay;
            limit_req_status 429;
        }

        # API endpoints
        location /api/ {
            limit_req zone=api burst=50 nodelay;
            limit_req_status 429;
        }

        # Regular web traffic
        location / {
            limit_req zone=web burst=100 nodelay;
        }
    }
}

limit_req_status 429 returns HTTP 429 (Too Many Requests) instead of the default 503 (Service Unavailable). 429 is the correct HTTP status for rate limiting and clients can handle it properly.

Connection Limiting

Different from request rate limiting — limit_conn restricts concurrent connections rather than request rate:

http {
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

    server {
        location /download/ {
            limit_conn conn_limit 5;
            limit_rate 500k;
        }
    }
}

limit_conn conn_limit 5 — max 5 concurrent connections per IP to this location.
limit_rate 500k — bandwidth limit per connection, 500KB/s.

This is great for download servers or large file endpoints where you don’t want one IP consuming all bandwidth.

Rate Limiting by Request Method

Limit only POST requests (form submissions, API writes) while leaving GET requests unrestricted:

limit_req_zone $binary_remote_addr zone=post_limit:10m rate=5r/s;

server {
    location /api/ {
        limit_req zone=post_limit burst=10 nodelay;

        if ($request_method = GET) {
            # Remove rate limiting for GET requests
            # Note: can't directly remove limit_req, use a different approach
        }
    }
}

Actually, a cleaner way to apply limits only to certain methods is using a map:

http {
    map $request_method $limit_key {
        POST $binary_remote_addr;
        PUT  $binary_remote_addr;
        default "";
    }

    limit_req_zone $limit_key zone=writes:10m rate=5r/s;

    server {
        location /api/ {
            limit_req zone=writes burst=10 nodelay;
        }
    }
}

When $limit_key is empty (GET requests), rate limiting doesn’t apply.

Whitelisting IPs

Don’t want rate limiting to affect your own servers or monitoring tools:

http {
    geo $limit {
        default 1;
        10.0.0.0/8 0;        # Internal network
        203.0.113.42 0;      # Monitoring server
    }

    map $limit $limit_key {
        0 "";                                  # Excluded IPs get empty key
        1 $binary_remote_addr;                 # Others get limited
    }

    limit_req_zone $limit_key zone=api:10m rate=30r/s;

    server {
        location /api/ {
            limit_req zone=api burst=50 nodelay;
        }
    }
}

The geo module maps IP ranges to values, and the map converts those to empty (no limit) or the IP (limited).

Handling Rate Limit Errors Gracefully

The default 503/429 response is plain text. You probably want something better:

server {
    limit_req_status 429;

    error_page 429 /429.html;

    location = /429.html {
        internal;
        root /var/www/errors;
        add_header Retry-After 60;
        add_header Content-Type text/html;
    }
}

Or return JSON for API endpoints:

location /api/ {
    limit_req zone=api burst=50 nodelay;
    limit_req_status 429;

    error_page 429 = @rate_limited;
}

location @rate_limited {
    default_type application/json;
    return 429 '{"error":"rate_limit_exceeded","message":"Too many requests. Please try again later."}';
}

Logging Rate-Limited Requests

By default, rate-limited requests appear in the error log. You can also track them in the access log:

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" '
                '"$http_user_agent" $request_time';

server {
    access_log /var/log/nginx/access.log main;
}

A 429 status in the access log tells you which IPs are hitting limits. I sometimes monitor this to distinguish attackers from legitimate clients that need the limits relaxed.

Tuning in Practice

When I deploy rate limiting, I start conservatively (higher limits) and monitor the logs. If legitimate users start getting 429s, I loosen the limits or add whitelists. If I’m still seeing abuse, I tighten them.

The login endpoint is usually where I’m most aggressive — 5 requests per minute per IP is the right ballpark for brute-force protection without affecting normal users. Even a slow typist doesn’t submit more than 1–2 login attempts per minute.

For general API endpoints, I find 30–50 requests per second per IP is usually generous enough for real users while still limiting bulk scrapers and bots.

Getting rate limiting right takes iteration. Don’t set it and forget it — keep an eye on your 429 responses for the first week after enabling it.

Scroll to Top