CoreMesh

How to Set Up Nginx Rate Limiting and Test It With curl

Configure nginx rate limiting with limit_req_zone and limit_req, pick burst and nodelay, return 429 instead of 503, and verify it with a curl loop.

How to Set Up Nginx Rate Limiting and Test It With curl

Nginx rate limiting is handled by ngx_http_limit_req_module, which ships with nginx and caps how many requests per second it will process for a given key, usually the client IP. You declare a shared memory zone with limit_req_zone in the http block, then apply it to a server or location with limit_req. Add a burst value so a normal page load with a few parallel requests isn't rejected, and set limit_req_status 429 so clients get the standard "too many requests" code instead of nginx's default 503.

There's no default rate limit in nginx. Until you add those directives, every request is processed as fast as the machine allows, which on a small VPS means one misbehaving crawler can push your app server over. Below is the minimal config, what burst and nodelay actually do, how to test the result with curl, and the mistakes that make people think the limit isn't working.

How nginx rate limiting works#

The module uses a leaky bucket. Each key (each IP, in the common case) gets a state entry in shared memory that tracks how many excess requests are outstanding. Requests arrive at whatever speed the client sends them, and nginx drains them at the configured rate. When the bucket is empty, a request is processed immediately. When requests arrive faster than the rate, the extra ones are either delayed so they trickle out at the configured rate, or rejected once the excess passes burst.

Three settings control it:

  • rate is the sustained throughput per key, in requests per second (10r/s) or per minute. Rates under one per second are written in r/m, so half a request per second is 30r/m.
  • burst is how many excess requests nginx will queue before it starts rejecting. The default is zero.
  • nodelay and delay decide whether queued requests wait for their slot or are served immediately while still counting against the bucket.

Since everything is keyed, "rate limiting by IP" and "rate limiting per user" are the same mechanism with a different key variable. The full directive reference is the ngx_http_limit_req_module documentation.

The smallest config that works#

The zone definition must live in the http context. The limit_req line goes wherever you want enforcement, and limit_req_status can sit at any of the three levels:

nginx
http {
    limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
    limit_req_status 429;

    server {
        listen 80;
        server_name example.com;

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

What each piece does:

  • $binary_remote_addr is the client IP in binary form, 4 bytes for IPv4 and 16 for IPv6. The docs recommend it over the text form $remote_addr because it keeps the state small.
  • zone=perip:10m names the zone and gives it 10 megabytes of shared memory. A state occupies 128 bytes on 64-bit platforms, so one megabyte holds about 8 thousand keys.
  • rate=10r/s is the sustained limit per IP.
  • limit_req_status 429 replaces the default 503 on rejected requests.
  • burst=20 nodelay lets a client get 20 requests ahead of the rate before rejections start, and serves them immediately instead of spacing them out.

Reload nginx and the limit is live. For a small VPS fronting a typical web app, I'd start with 10r/s and a burst of 20, then adjust after watching the error log for a day. The right numbers depend on how many requests one page view generates and how much your backend can take.

What burst, nodelay and delay actually do#

This is where most confusion comes from. With burst=0, the default, any request that arrives faster than the rate is rejected on the spot. A browser loading a page with ten assets would get 429s on most of them, because a page load is a burst by definition. That's why you almost always want a burst value.

With burst=5 and nothing else, excess requests are queued and released one per rate interval. At 1r/s the sixth request in a burst waits around five seconds. The client gets a slow response, not an error, and only requests beyond the burst are rejected. The docs describe this as not more than 1 request per second on average, with bursts not exceeding 5 requests.

With burst=5 nodelay, those five excess requests are served instantly, but they still take up slots in the bucket, and the slots refill at the configured rate. From the client's side: fast until you hit the ceiling, then 429 until the bucket drains.

The delay=N parameter (since 1.15.7) is the middle ground. The first N excess requests go through without waiting, the rest of the burst is delayed, and anything beyond burst is rejected. delay=0 is the default, meaning every excess request waits. nodelay is effectively a delay equal to the burst.

Here's a small model of that accounting. It takes a batch of requests from one client arriving at the same instant and shows how each configuration treats them:

python
# A model of limit_req accounting: excess is tracked in 1/1000 of a request.
# All requests come from one client at the same instant, so nothing drains.

def simulate(rate, burst, delay, n, label):
    excess = 0
    print(label)
    for i in range(1, n + 1):
        if i == 1:
            print(f"  request {i:2d}: PASSED (new state)")
            continue
        candidate = excess + 1000
        if candidate > burst * 1000:
            print(f"  request {i:2d}: REJECTED")
        elif candidate > delay * 1000:
            wait = (candidate - delay * 1000) / (rate * 1000)
            excess = candidate
            print(f"  request {i:2d}: DELAYED about {wait:.0f}s")
        else:
            excess = candidate
            print(f"  request {i:2d}: PASSED")
    print()

simulate(rate=1, burst=5, delay=0, n=9, label="rate=1r/s burst=5 (default delay=0)")
simulate(rate=1, burst=5, delay=5, n=9, label="rate=1r/s burst=5 nodelay")
simulate(rate=1, burst=5, delay=3, n=9, label="rate=1r/s burst=5 delay=3")

Rejections are logged at the limit_req_log_level (default error), and delays one level below that. Since 1.17.6 you can add $limit_req_status to your log format. It reads PASSED, DELAYED or REJECTED, which is the quickest way to see what the module decided for each request.

Rate limiting per IP, per server, per user or by header#

The key in limit_req_zone can be text, variables, or a combination (one variable only before 1.7.6). That opens up a few useful patterns.

Per IP plus a ceiling per virtual host. The docs' own example stacks two zones, so one client can't exceed its share and the server as a whole has a cap too:

nginx
limit_req_zone $binary_remote_addr zone=perip:10m rate=1r/s;
limit_req_zone $server_name zone=perserver:10m rate=10r/s;

server {
    limit_req zone=perip burst=5 nodelay;
    limit_req zone=perserver burst=10;
}

Per user or per API key. Use a request header as the key:

nginx
limit_req_zone $http_x_api_key zone=perkey:10m rate=5r/s;

One detail from the docs matters here: requests with an empty key aren't accounted at all. A client that simply omits the header is invisible to this zone. Always pair a header-based zone with a per-IP zone so anonymous traffic is still covered.

Different limits per location. limit_req is inherited from the parent level only if the current level has no limit_req of its own. So you can set a lenient default at server level and tighten an expensive endpoint:

nginx
server {
    limit_req zone=perip burst=20 nodelay;

    location /search/ {
        limit_req zone=perip burst=3;
    }
}

The override replaces every inherited limit_req, not just the one you changed. If server applied two zones, /search/ above now has only one.

Variables inside rate= (so you could map a rate per URI) are a commercial subscription feature since 1.31.3. On open-source nginx, use separate zones per path.

How to test nginx rate limiting with curl#

Most "not working" reports come from a sloppy test, so do it deliberately. Start with a single request that prints only the status code:

bash
curl -s -o /dev/null -w "%{http_code}\n" http://example.com/

Then send a burst. With rate=10r/s burst=20 nodelay, a tight loop of 40 requests should show the first twenty-odd as 200 and the rest as 429, with a few more successes if the loop runs slowly enough for the bucket to drain:

bash
for i in $(seq 1 40); do
  curl -s -o /dev/null -w "%{http_code} " http://example.com/
done; echo

If you configured burst without nodelay, don't expect 429s from that loop. Excess requests are delayed, not rejected, until the burst is exhausted. Add %{time_total} to the -w format to see the delay instead:

bash
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://example.com/

Two things to keep in mind. Run the test from a machine other than the server, or at least remember that every request from one box shares one key, so your own monitoring or deploy scripts count against the same bucket. And if a CDN or load balancer sits in front, test through it, because what nginx sees as the client address may be the proxy.

For a production rollout, turn on limit_req_dry_run on; (since 1.17.1). Nginx then counts excess requests in the zone and logs them, but doesn't delay or reject anything. $limit_req_status shows REJECTED_DRY_RUN or DELAYED_DRY_RUN for the requests that would have been affected. Run that for a day, read the log, then switch dry run off. The -w variables used above are documented in the curl manual.

Picking a rate and sizing the zone#

The zone size decides how many distinct keys nginx can track at once. When the zone fills up, the least recently used state is evicted. If even that doesn't free enough room, the request is rejected with the limit_req_status code, so an undersized zone turns into spurious 429s under heavy traffic. The docs give the numbers: 64 bytes per state on 32-bit platforms, 128 bytes on 64-bit.

javascript
// Keys a limit_req_zone can hold, using the per-state sizes from the nginx docs:
// 64 bytes on 32-bit platforms, 128 bytes on 64-bit.
for (const size of ["1m", "10m", "64m"]) {
  const bytes = parseInt(size, 10) * 1024 * 1024;
  const on64 = Math.floor(bytes / 128);
  const on32 = Math.floor(bytes / 64);
  console.log(`${size.padStart(3)}: about ${on64.toLocaleString("en-US")} keys on 64-bit, ${on32.toLocaleString("en-US")} on 32-bit`);
}

For a single VPS, 10m is plenty for a per-IP zone, and there's no harm giving a per-API-key zone the same.

On the rate itself, count the requests one page view triggers in your browser's network tab, then set burst somewhat above that and rate to what your backend handles comfortably per client. Static assets served directly by nginx can usually sit in a location without limit_req at all, since they're cheap and would otherwise eat the burst.

Common mistakes when the limit isn't working#

  • Expecting 429 and seeing 503. 503 is the default limit_req_status. Set it to 429 explicitly.
  • limit_req_zone inside server. It's only valid in http. Nginx refuses the config.
  • No burst. With burst=0 a normal page load gets rejected. Add a burst before concluding the limit is too aggressive.
  • Burst without nodelay, tested only on status codes. Everything returns 200, just slowly. Check response time, or log $limit_req_status.
  • Testing from the server itself. All requests share one key, including health checks and cron jobs.
  • Nginx behind a proxy or CDN. $binary_remote_addr is the proxy's address, so every visitor shares one bucket and you end up rate limiting your own CDN. Nginx has to be told to trust the forwarded client address first, which is a separate module and outside this page.
  • A location that overrides the inherited limit. Any limit_req in a location replaces all limit_req lines from server. Re-add the ones you still want.
  • Fractional r/s. Write 30r/m, not 0.5r/s.
  • Header key with no fallback. Empty keys aren't counted, so a missing header bypasses that zone. Stack a per-IP zone next to it.
  • Dry run left on. limit_req_dry_run on logs but never rejects. Easy to forget after a trial.

Quick reference#

Directive Context Default Purpose
limit_req_zone key zone=name:size rate=r; http none Define the shared memory zone and its rate
limit_req zone=name [burst=n] [nodelay or delay=n]; http, server, location none Apply the zone; burst defaults to 0
limit_req_status code; http, server, location 503 Status code for rejected requests
limit_req_log_level level; http, server, location error Log level for rejections; delays log one level lower
limit_req_dry_run on or off; http, server, location off Count and log excess, but don't enforce

Checklist before you call it done:

  1. Zone defined in http with $binary_remote_addr and a 10m size.
  2. limit_req applied in the right location, with a burst big enough for one page load.
  3. limit_req_status 429 set.
  4. Tested with a curl loop from a different machine, and the 429s actually showed up.
  5. $limit_req_status in the access log so you can see decisions later.
  6. Dry run turned off.

Quick answers

Does nginx have a default rate limit?

No. Nothing is limited until you define a zone with limit_req_zone and apply it with limit_req. Once applied, the default burst is 0 and the default rejection status is 503.

How do I rate limit by IP in nginx?

Use $binary_remote_addr as the key in limit_req_zone, for example limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s; then add limit_req zone=perip burst=20 nodelay; in the location you want protected.

Why does nginx return 503 instead of 429 when rate limiting?

503 is the default value of limit_req_status. Add limit_req_status 429; in the http, server or location block to return the standard too-many-requests code.

Why is my nginx rate limit not working?

The usual causes are testing from the server's own IP, a burst without nodelay that delays rather than rejects, a location whose own limit_req overrides the inherited one, or a proxy in front so every visitor shares one address. Logging $limit_req_status shows what nginx decided per request.

How does nginx rate limiting work internally?

It's a leaky bucket per key stored in shared memory. Requests over the rate build up an excess counter; while the excess is within burst they're delayed or, with nodelay, served immediately, and beyond burst they're rejected.

Did this solve it?

One tap. No account.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

On a computer? Scan the code with your phone wallet, or copy the address.

Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.

Discussion

Start the discussion

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS