How to Implement Rate Limiting in Nginx

How to Implement Rate Limiting in Nginx

How to Implement Rate Limiting in Nginx

I first got serious about rate limiting after watching a client’s login endpoint get hammered by a credential-stuffing bot — tens of thousands of login attempts in under an hour, from a rotating pool of IPs, all aimed at the same /login route. The application itself never crashed, but the database connection pool did, taking the whole site down with it. A few lines of Nginx configuration would have stopped that attack cold before it ever reached the app. Since then, rate limiting has become one of the first things I configure on any public-facing Nginx server, right alongside SSL.

This guide covers everything I use in practice: request rate limiting, connection limiting, burst handling, and how to apply different limits to different parts of an application.

Why Rate Limiting Matters

Rate limiting protects against several distinct problems:

Requirements

Step 1: Understanding the Two Rate Limiting Modules

Nginx provides two separate mechanisms:

These solve different problems and are often used together. limit_req is great for controlling request frequency (login attempts, API calls); limit_conn is great for preventing a single client from opening dozens of simultaneous connections (common in download-heavy or streaming scenarios).

Step 2: Setting Up Request Rate Limiting

First, define a rate limiting zone in the http block (in nginx.conf or a file included from it):

http {
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
}

Breaking this down:

Now apply the zone within a location block:

server {
    location /api/ {
        limit_req zone=general burst=20 nodelay;
        proxy_pass http://127.0.0.1:3000;
    }
}

Step 3: Applying Stricter Limits to Sensitive Endpoints

I always apply tighter limits specifically to login, registration, and password reset endpoints, since these are the most common targets for automated abuse:

http {
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
}

server {
    location /login {
        limit_req zone=login burst=5 nodelay;
        proxy_pass http://127.0.0.1:3000;
    }

    location /api/ {
        limit_req zone=general burst=20 nodelay;
        proxy_pass http://127.0.0.1:3000;
    }
}

Here, /login allows only 1 request per second with a burst of 5, while general API traffic gets a much more generous 10 requests per second. This kind of tiered approach — strict on high-risk endpoints, relaxed on normal browsing/API traffic — is the pattern I use on nearly every production site.

Step 4: Limiting Concurrent Connections

For scenarios like file downloads or streaming, where a single request might be long-lived, request-rate limiting alone doesn’t help. This is where limit_conn comes in:

http {
    limit_conn_zone $binary_remote_addr zone=addr:10m;
}

server {
    location /downloads/ {
        limit_conn addr 3;
        proxy_pass http://127.0.0.1:3000;
    }
}

This restricts each client IP to at most 3 simultaneous connections to /downloads/. This is particularly effective against download managers or scripts trying to grab many files in parallel to bypass bandwidth throttling.

Step 5: Custom Error Responses for Rate-Limited Requests

By default, rate-limited requests get a bare 503 Service Temporarily Unavailable. I prefer customizing this to be more informative and to use the more semantically correct 429 Too Many Requests status code:

server {
    limit_req_status 429;
    limit_conn_status 429;

    location /api/ {
        limit_req zone=general burst=20 nodelay;
        proxy_pass http://127.0.0.1:3000;
    }

    error_page 429 /429.json;
    location = /429.json {
        internal;
        default_type application/json;
        return 429 '{"error": "Too many requests. Please slow down and try again shortly."}';
    }
}

This gives API clients a proper JSON error response with the correct status code, which well-behaved clients can use to implement backoff logic automatically.

Step 6: Rate Limiting Based on Other Keys

While IP-based limiting is the most common approach, you can key rate limits on other request attributes. For an authenticated API, limiting by API key rather than IP is often more accurate, since many legitimate users can share an IP (corporate NAT, mobile carrier NAT):

http {
    limit_req_zone $http_x_api_key zone=api_key_limit:10m rate=100r/s;
}

server {
    location /api/ {
        limit_req zone=api_key_limit burst=50 nodelay;
        proxy_pass http://127.0.0.1:3000;
    }
}

You can also combine multiple keys using a map directive for more sophisticated logic — for instance, exempting known internal IPs from rate limiting entirely:

geo $limit_bypass {
    default 0;
    10.0.0.0/8 1;
    203.0.113.5 1;
}

map $limit_bypass $limit_key {
    0 $binary_remote_addr;
    1 "";
}

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

When $limit_key evaluates to an empty string, Nginx effectively doesn’t apply the rate limit for that request, since all such requests share a single (unlimited-in-practice) empty key — this is the standard technique for whitelisting internal traffic.

Step 7: Whitelisting Specific IPs Entirely

For monitoring services, internal health checks, or trusted partners, sometimes it’s simpler to bypass rate limiting entirely with geo combined with the map pattern above, or by placing exempted routes in a separate location block without the limit_req directive at all:

location /health {
    # No limit_req applied here
    proxy_pass http://127.0.0.1:3000;
}

Testing Rate Limits

I test rate limiting with a simple loop using curl:

for i in {1..30}; do
  curl -o /dev/null -s -w "%{http_code}\n" http://example.com/api/test
done

You should see a mix of 200 responses up to your configured rate/burst, followed by 429 (or 503) responses once the limit is exceeded.

For more realistic load testing, I use ab (Apache Bench) or wrk:

ab -n 100 -c 20 http://example.com/api/test

Check your Nginx error log during testing — rate-limited requests are logged there by default:

tail -f /var/log/nginx/error.log | grep limiting

You’ll see entries like:

2026/08/15 11:02:14 [error] 1523#1523: *201 limiting requests, excess: 10.500 by zone "general", client: 203.0.113.45

Troubleshooting Common Issues

Legitimate users getting rate limited — Your rate or burst values are too aggressive for real usage patterns. Browsers commonly fire 5-10+ requests simultaneously when loading a page (HTML, CSS, JS, images, fonts, API calls). Increase burst and/or add nodelay, or raise the base rate.

Rate limiting not applying at all — Double check the zone is actually referenced inside a location block with limit_req zone=yourzone; — defining the zone in http alone does nothing without an explicit limit_req directive applying it somewhere.

All users behind a corporate NAT getting blocked together — This is the classic downside of IP-based limiting. Consider keying by a more granular identifier (API key, session cookie) if this becomes a real problem for your user base.

Rate limits reset unexpectedly after Nginx reload — This is actually expected behavior: limit_req_zone state lives in shared memory that gets reinitialized when Nginx restarts (though a reload alone, without a full restart, generally preserves it since worker processes are recycled gracefully — test this specifically in your environment if it matters for your use case).

Security Considerations

Performance Tips

Real-World Use Cases

Best Practices I Follow

  1. Apply stricter limits to authentication and other sensitive endpoints than to general traffic.
  2. Always use burst to accommodate legitimate traffic spikes — a zero-burst config is almost always too aggressive.
  3. Return 429 Too Many Requests with a clear, machine-readable error body rather than a bare 503.
  4. Whitelist trusted internal IPs and health-check endpoints explicitly.
  5. Test rate limits under realistic traffic patterns before deploying to production, not just synthetic single-request loops.
  6. Monitor error logs for rate limit triggers as an early warning signal of abuse.
  7. Layer Nginx rate limiting with application-level protections (account lockouts, CAPTCHA) rather than relying on it alone.
  8. Reassess rate limit thresholds periodically as your traffic patterns evolve.

Wrapping Up

Rate limiting in Nginx is inexpensive to set up and genuinely effective against a wide range of common abuse patterns — brute-force attempts, scraper abuse, accidental self-inflicted overload, and smaller-scale denial-of-service attempts. The configuration itself is only a few lines, but getting the thresholds right takes a bit of observation of your actual traffic. I’d suggest starting conservative on sensitive endpoints like login forms, monitoring your error logs for a week to see how real users interact with your limits, and adjusting from there. It’s a small amount of upfront work that can prevent a genuinely bad night dealing with an active attack.

Exit mobile version