← Back to Blog

429 Too Many Requests in Nginx: Who Sent It, How limit_req Works, and the Fix We Tested

Published · by Leo Gashi, RS Computers

Nginx Cloudflare Troubleshooting

"429 Too Many Requests" with an nginx or nginx/1.26.3 footer means the server decided one client sent requests faster than a configured limit allowed, and refused the extra ones instead of serving them. On your own server it usually comes from nginx's limit_req or limit_conn with limit_req_status 429, but the same status can come from the application behind nginx or from Cloudflare in front of it, so the first job is to find out which of the three sent it.

Every output below comes from a test we ran on 11 November 2026: a fresh Debian 13 container with nginx 1.26.3 from Debian's own packages, hit with curl loops. RS Computers runs KVM VPS and VDS servers in Amsterdam, Dublin and Prishtina with full root access, so on one of our servers in Amsterdam you edit these files yourself; the steps are the same on any Debian or Ubuntu server.

Key facts, checked on 11 November 2026:

First find out who sent the 429

Three different programs can send this page. Request the URL yourself, read the headers, then check the logs. All commands in this article ran as root; on your server use a root shell or put sudo in front.

curl -si https://example.com/ | head -n 12
tail -n 20 /var/log/nginx/error.log
What you seeWho sent the 429Fix
server: cloudflare, a cf-ray header, or a Cloudflare "Error 1015" page; nothing in your nginx logsCloudflare, from a rate limiting rule on the zoneEdit or relax the zone's rate limiting rule in the Cloudflare dashboard
limiting requests, excess: ... by zone "..." in error.logYour nginx, limit_reqRaise rate or burst, add nodelay, exclude static files, exempt your own IPs
limiting connections by zone "..." in error.logYour nginx, limit_connRaise the connection count; remember each HTTP/2 stream counts as one
Every visitor in error.log shows the same client: address, often 127.0.0.1, 10.x or a Cloudflare IPYour nginx, but limiting the proxy instead of visitorsset_real_ip_from plus real_ip_header (see the Cloudflare section)
429 in access.log, no line in error.log, upstream status 429, often a Retry-After or JSON bodyThe application behind nginx (WordPress plugin, Laravel throttle, an API gateway)Change the app's own limit; nginx only passed the answer on
Your site works but a feature fails; the app log shows 429 from an outside URLA remote API your server callsRespect Retry-After, cache, authenticate, back off; not an nginx problem

Headers alone do not separate nginx from the app: our test app's own 429 still arrived with Server: nginx/1.26.3, because nginx sets that header on everything it forwards. The logs do, as shown further down.

How limit_req works: rate, burst, nodelay and delay

The ngx_http_limit_req_module uses a leaky bucket per key. You define the bucket once in the http context and switch it on where you want it:

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

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

The key is the client address in binary form. 10m of shared memory holds roughly 80,000 clients. The rate can be per second (r/s) or per minute (r/m), and it is spread evenly: 5r/m means one request every 12 seconds, not five at once and then a quiet minute.

Without the limit_req_status line our five quick requests came back as 200 503 503 503 503. With it, the same loop gave:

200 429 429 429 429

That is the tutorial configuration, and it is too strict for a website: one page view often fires several requests in the same second.

What burst changes, measured

To see the three modes, we sent 8 requests at the same instant with rate=1r/s, using this loop that starts all curls in the background and waits for them:

for i in $(seq 1 8); do curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://127.0.0.1/ & done; wait

limit_req zone=perip burst=5; queued the extras and released them one per second. Two were over the queue and got 429 at once:

429 0.000371s
429 0.000447s
200 0.000513s
200 1.002014s
200 2.002737s
200 3.001255s
200 4.003356s
200 5.003458s

limit_req zone=perip burst=5 nodelay; served all six allowed requests immediately and refused the other two (lines sorted by time). The burst slots then free up again at the rate, one per second:

429 0.000307s
200 0.000380s
200 0.000400s
429 0.000448s
200 0.000451s
200 0.000472s
200 0.000554s
200 0.000583s

limit_req zone=perip burst=5 delay=2; is the middle way added in nginx 1.15.7: the first excess requests up to the delay value pass at once, the rest of the burst is paced:

429 0.000300s
429 0.000314s
200 0.000614s
200 0.001312s
200 0.001348s
200 1.000978s
200 2.001808s
200 3.002532s

For web pages, burst with nodelay is usually right: visitors never wait, and a script that keeps hammering runs out of slots. Without it, a delayed visitor watches a spinning page, which looks like a slow site.

Reading the "limiting requests" lines in error.log

Each refused request writes one line to /var/log/nginx/error.log. This one is from the no-burst test:

2026/11/11 12:26:09 [error] 585#585: *3 limiting requests, excess: 0.984 by zone "perip", client: 127.0.0.1, server: example.test, request: "GET / HTTP/1.1", host: "127.0.0.1"

by zone "perip" names the limit_req_zone that refused it. excess is how many requests this key is currently over the rate; nginx refuses when that number would go past burst. In the burst=5 test the refused requests showed excess: 5.998, a full bucket. client: is the key nginx used; if it is the same for everybody, read the Cloudflare section before changing any number.

To count which zone and which client trigger most often:

grep -o 'by zone "[^"]*", client: [^,]*' /var/log/nginx/error.log | sort | uniq -c | sort -rn | head

Delayed requests are missing from that log on stock Debian: nginx logs delays one level below refusals, at warn, and Debian's error_log line records only error. With error_log /var/log/nginx/error.log warn; in the server block we got:

2026/11/11 12:29:52 [warn] 1252#1252: *1 delaying request, excess: 1.000, by zone "perip", client: 127.0.0.1, server: example.test, request: "GET / HTTP/1.1", host: "127.0.0.1"

With limit_conn conns 1 on a download path, the second of two parallel downloads got 429 in under a millisecond:

[error] 1157#1157: *61 limiting connections by zone "conns", client: 127.0.0.34, server: example.test, request: "GET /big/file.bin HTTP/1.1", host: "127.0.0.1"

Be careful with limit_conn on pages. The limit_conn documentation says that in HTTP/2 and HTTP/3 "each concurrent request is considered a separate connection", so a low number breaks normal pages. Keep it for downloads.

A rate limit configuration for a typical site

A loose zone for pages, a strict one for the login form, a middle one for the API, and no limit on static files. The zone lines go in the http context (any file in /etc/nginx/conf.d/), the locations in your site's server block. This is the file we loaded:

limit_req_zone $binary_remote_addr zone=pages:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m   rate=5r/s;
limit_req_status 429;

server {
    listen 80 default_server;
    server_name example.test;
    root /var/www/html;

    # pages: 10 per second on average, 20 extra at once without waiting
    location / {
        limit_req zone=pages burst=20 nodelay;
    }

    # static files: no limit
    location ~* \.(css|js|png|jpe?g|gif|svg|webp|ico|woff2?)$ {
        expires 7d;
    }

    # login form: 5 a minute, 3 quick retries
    location /login/ {
        limit_req zone=login burst=3 nodelay;
    }

    # API: 5 a second, burst of 10, JSON answer with Retry-After
    location /api/ {
        limit_req zone=api burst=10 nodelay;
        error_page 429 = @too_many;
    }

    location @too_many {
        default_type application/json;
        add_header Retry-After 2 always;
        return 429 '{"error":"too many requests"}\n';
    }
}

Then nginx -t and systemctl reload nginx. To test, send requests in a loop and print only the status codes; change the count and the URL per path:

for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code} " http://127.0.0.1/; done; echo

From one address we got (condensed: "200 x 22" means 22 times 200 in a row):

static  50 requests: 200 x 50
pages   30 requests: 200 x 22, then 429 429 429 429 200 429 429 429
login    6 requests: 200 200 200 200 429 429
api     14 requests: 200 x 11, then 429 429 429

One plus a burst of 20 is 21; at 10r/s a slot refills every 100 ms, hence the 22nd 200 and the lone one among the 429s. Login allowed 1 plus 3, the API 1 plus 10. The API refusal came with a body a client program can read, and the Retry-After header that nginx's own error page does not send:

HTTP/1.1 429 Too Many Requests
Server: nginx
Content-Type: application/json
Content-Length: 30
Retry-After: 2

{"error":"too many requests"}

Two traps we hit while testing

First, inheritance. We moved limit_req zone=pages burst=20 nodelay; up into the server block. The static location had no limit_req of its own, so it inherited the server one, and 30 quick requests for style.css started getting 429 after the 22nd. The docs spell out the rule: limit_req is inherited from the level above only if the current level has none. So set limits per location, or give the static location nothing and keep the limit out of server.

Second, WordPress. wp-login.php is caught by your location ~ \.php$ block, so a /login/ prefix does nothing there. An exact match wins over a regex (location docs), so give the login its own block with the strict zone and your usual include fastcgi_params and fastcgi_pass lines. In our test location = /wp-login.php { limit_req zone=login burst=3 nodelay; } gave 200 200 200 200 429 429, while other .php URLs kept the looser page limit.

While tuning, know that a reload does not reset the counters. Our login zone still remembered a test from half a minute earlier, so after a reload only the first login request passed; systemctl restart nginx cleared it.

If you are not sure about the numbers, run in dry mode first. limit_req_dry_run on; counts and logs as if limiting, but lets everything through. All five of our requests got 200, and the log showed what would have happened:

[error] 1156#1156: *57 limiting requests, dry run, excess: 0.985 by zone "dry", client: 127.0.0.33, server: example.test, request: "GET /dry/ HTTP/1.1", host: "127.0.0.1"

Behind Cloudflare or a load balancer, every visitor is one IP

This is the most common reason real people suddenly get 429. Behind a proxy, $binary_remote_addr is the proxy's address, so all visitors share one bucket. We put a second nginx in front, standing in for Cloudflare and passing a CF-Connecting-IP header. Visitor A sent 25 requests; visitor B, from another address, then sent 3:

visitor A: 200 x 22, then 429 429 429
visitor B: 429 200 429

Visitor B was refused on the first request without having done anything, and the log blamed the proxy:

limiting requests, excess: 20.860 by zone "pages", client: 127.0.0.1, server: example.test, request: "GET / HTTP/1.0", host: "example.test"

The fix is the ngx_http_realip_module: tell nginx which addresses are your proxies and which header carries the visitor. The module is not built by default from source, but Debian's package includes it; nginx -V 2>&1 | tr ' ' '\n' | grep realip printed --with-http_realip_module. In our lab the proxy was 127.0.0.1:

set_real_ip_from 127.0.0.1;
real_ip_header CF-Connecting-IP;

After a restart, the same test gave visitor A 200 x 22, then 429 429 429 and visitor B 200 200 200, and the log named the real visitor, client: 127.0.0.11.

For Cloudflare, the trusted addresses are its published ranges on cloudflare.com/ips, and Cloudflare's guide to restoring visitor IPs uses the same two directives with CF-Connecting-IP. This builds the file from the live lists:

{
  for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do
    echo "set_real_ip_from $ip;"
  done
  echo "real_ip_header CF-Connecting-IP;"
} > /etc/nginx/conf.d/cloudflare-realip.conf
wc -l /etc/nginx/conf.d/cloudflare-realip.conf
nginx -t

On 11 November 2026 it wrote 23 lines, 22 ranges plus the header line, starting with set_real_ip_from 173.245.48.0/20;. If wc -l shows 1, the download failed; run it again. Cloudflare says the list changes, so repeat it now and then. Behind your own load balancer, trust its private address or subnet and use real_ip_header X-Forwarded-For; with real_ip_recursive on; if there is more than one proxy hop.

Do not shortcut this with set_real_ip_from 0.0.0.0/0;. Then anyone can write the header. We sent 25 requests straight to nginx, each with a different made-up CF-Connecting-IP, and all 25 got 200. With only the real proxy trusted, the forged header was ignored and the 23rd request got 429. Our reverse proxy and free SSL guide covers the rest of the proxy setup.

Exempt your own IPs with geo and map

Uptime monitors, your office, a payment provider's callbacks or your own scripts should not be limited. nginx does not count a request whose key is empty, so the clean way is to make the key empty for those addresses with the geo and map modules, in the http context:

geo $rl_exempt {
    default     0;
    127.0.0.30  1;   # e.g. your uptime monitor
    10.0.0.0/8  1;   # e.g. your office VPN
}
map $rl_exempt $rl_key {
    0 $binary_remote_addr;
    1 "";
}
limit_req_zone $rl_key zone=pages:10m rate=10r/s;

From the exempt address, 25 fast requests were all 200. From a normal one the same loop gave 22 times 200 and then 429. If you use the realip setup above, geo sees the restored visitor address, so list the visitor IPs you want to exempt, not Cloudflare's.

Your nginx or the app behind it?

To be sure who refused a request, put two variables into the access log. $limit_req_status (nginx 1.17.6 and later) says what the limiter decided, and $upstream_status what the backend answered (log module):

log_format rl '$remote_addr "$request" $status limit=$limit_req_status up=$upstream_status';
access_log /var/log/nginx/rl.log rl;

Our test app behind proxy_pass answered 429 with Retry-After: 30. The lines show the difference plainly:

127.0.0.32 "GET /app/ HTTP/1.1" 429 limit=PASSED up=429
127.0.0.31 "GET / HTTP/1.1" 429 limit=REJECTED up=-

The first is the app: nginx let it through and passed the answer on, with nothing in error.log. The second is nginx itself. With up=429, look at the application (a security plugin's login throttle, framework throttle middleware, an API gateway); raising nginx's numbers changes nothing.

When the 429 comes from an API your server calls

Sometimes your PHP or Node code calls an outside API, the API says "too many requests", and your page breaks. That is in your application log, not nginx's. Most APIs send their limits in headers; from our lab container, GitHub answered:

curl -sI https://api.github.com/ | grep -iE "^(HTTP|x-ratelimit|retry-after)"
HTTP/2 200
x-ratelimit-limit: 60
x-ratelimit-remaining: 59
x-ratelimit-used: 1
x-ratelimit-resource: core
x-ratelimit-reset: 1794403763

GitHub allows 60 unauthenticated requests per hour per IP address, answers 403 or 429 past it, and asks clients to wait for retry-after or until x-ratelimit-reset (GitHub REST API rate limits). The fix is in your code: authenticate, cache responses, honour Retry-After and back off with growing waits. Per-IP limits are also why your own IPv4 address helps: on shared hosting, other customers can use up the same address's quota. Our guide to running crawlers on a VPS shows a polite retry loop.

If a 429 arrives with server: cloudflare and a cf-ray header, or as "Error 1015", a Cloudflare rate limiting rule stopped it. Cloudflare's own page for error 1015 says the website owner sets those rules, so the fix is in the dashboard, not on the server.

Frequently asked questions

What does "429 Too Many Requests nginx/1.26.3" mean?

The nginx 1.26.3 on that server refused the request because the client went over a rate or connection limit, usually limit_req with limit_req_status 429. 1.26.3 is the version Debian 13 ships. The version in the footer is not the cause; it only shows server_tokens is on. If you own the server, the matching "limiting requests" line in /var/log/nginx/error.log names the zone and client.

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

503 is the default for both limit_req_status and limit_conn_status. Add limit_req_status 429; and limit_conn_status 429; in the http block. 429 says what really happened, rate limiting as RFC 6585 defines it, while a 503 in your logs and monitoring looks like the server was down.

Does reloading nginx reset the rate limit counters?

No. In our test the zone kept its state across systemctl reload nginx as long as its name and size were unchanged. systemctl restart nginx cleared it.

Why do all my visitors get 429 behind Cloudflare?

nginx is limiting by the Cloudflare server's address, so many visitors share one bucket. Add set_real_ip_from for every Cloudflare range plus real_ip_header CF-Connecting-IP;, then check that error.log shows visitor addresses.

What is a good limit_req rate for WordPress?

A reasonable start is the one we tested: 10r/s with burst=20 nodelay for pages, static files not limited, and 5r/m with burst=3 nodelay on an exact location = /wp-login.php. Run it with limit_req_dry_run on; for a day, look at the log, then switch it on.

I get 429 on someone else's website. What can I do?

Wait; a Retry-After header, if present, says how many seconds. Refreshing keeps you over the limit. If it never clears, contact the site owner.

Which server to run it on

Rate limiting costs nginx almost nothing, so the site behind it decides the server size. For a blog or a small shop, a VPS Nano or VPS Micro (1 or 2 shared vCPUs, NVMe, 1 Gb/s port) is enough. For an API or a busy shop, the VDS plans have dedicated vCPUs and a 10 Gb/s port. Every RS Computers server in Amsterdam, Dublin and Prishtina comes with full root, a dedicated IPv4 and IPv6 address and free weekly backups, and it is deployed automatically in under 60 seconds after payment; compare them on the VPS and VDS plans page. For the other two errors in this series, read our guide to fixing 502 Bad Gateway in nginx and the guide to the 413 Request Entity Too Large fix. If your log shows a limiting line you cannot place, send it to us on Telegram.

← All articles

Chat on Telegram