
Rate Limit APIs with NGINX Without Blocking Bursts
Rate Limit APIs with NGINX Without Blocking Bursts
An API can be healthy at normal traffic levels and still fall over when one client retries too aggressively, a bot scans an expensive endpoint, or a login form receives a credential-stuffing burst. Application-level limits are useful, but rejecting excess traffic at the reverse proxy protects the application before it spends a database connection, queue slot, or CPU cycle.
NGINX provides request-rate limiting through ngx_http_limit_req_module. It uses a leaky-bucket model: you define a key, a shared memory zone, and an average request rate, then decide how much short-term burst traffic to tolerate. The difficult part is not writing one directive. It is choosing the correct client identity, understanding burst behavior, returning a useful status, and rolling the rule out without blocking legitimate users.
As verified on September 6, 2026, nginx.org lists NGINX 1.31.5 as mainline and 1.30.4 as stable. The core directives used below have existed for many earlier releases, so the configuration also works on common supported distribution packages.
Start with the threat and the unit you want to limit
Rate limiting is most useful when the key represents the actor consuming a constrained resource. A public endpoint commonly uses the client IP address. An authenticated API may be better limited by account, API key, tenant, or a combination of identity and route.
This tutorial starts with IP-based limits because NGINX always knows the network peer. Keep these limitations in mind:
- many legitimate users may share one corporate or carrier NAT address;
- one attacker can distribute requests across many addresses;
- IPv6 clients may rotate addresses within a prefix;
- a reverse proxy can hide the original address unless real-IP handling is configured safely;
- IP limits cannot replace account lockouts, quotas, authorization, or abuse detection.
Use the proxy rule as one defensive layer. Enforce durable business quotas in the application where authenticated identity and billing context are available.
Define shared rate-limit zones
limit_req_zone belongs in the http context. It defines the key, shared-memory zone, and average rate. The following file creates separate policies for a general API and a sensitive login endpoint:
# /etc/nginx/conf.d/rate-limits.conf
limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_per_ip:10m rate=5r/m;
$binary_remote_addr stores IPv4 and IPv6 addresses more compactly than the text form. A 10 MB zone holds state for many thousands of addresses, although exact capacity depends on platform architecture. If the zone fills, NGINX removes least-recently-used state; if it still cannot allocate a new entry, the request is rejected.
The configured rate is an average, not a fixed-window counter. 10r/s means the limiter drains at ten requests per second. For rates below one request per second, use requests per minute, such as 5r/m for a login attempt every 12 seconds on average.
Do not create a separate zone for every URL unless those routes genuinely need different policies. Shared zones consume memory and should represent intentional resource classes.
Apply limits and allow realistic bursts
Reference the zones inside the relevant server or location blocks:
server {
listen 443 ssl;
server_name api.example.com;
location /api/ {
limit_req zone=api_per_ip burst=20 nodelay;
limit_req_status 429;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://app_backend;
}
location = /api/login {
limit_req zone=login_per_ip burst=3 nodelay;
limit_req_status 429;
add_header Retry-After 12 always;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://app_backend;
}
}
Without burst, a client that sends requests slightly faster than the drain rate can be rejected immediately. burst=20 gives the general API room for short client-side concurrency. By default, excess requests within that allowance are delayed. Adding nodelay allows the accepted burst to proceed immediately while NGINX continues accounting at the configured average rate. Requests that exceed the available burst capacity are rejected.
Choose nodelay when a short, accepted burst is cheaper than adding latency. Omit it when smoothing traffic is valuable and clients can tolerate queued requests. Never copy a large burst value without checking how much concurrent load the upstream can absorb.
NGINX defaults rejected requests to HTTP 503. For an API, limit_req_status 429 more accurately communicates that the client sent too many requests. A Retry-After header can help for a predictable low-rate endpoint, but do not send a misleading value when several policies or dynamic quotas apply.
Preserve the real client IP safely
If clients connect directly to NGINX, $remote_addr is already the network client and no extra work is needed. If a trusted load balancer or reverse proxy sits in front, $remote_addr is initially the proxy address. Limiting on that value would put every visitor into one bucket.
The real-IP module can replace it from a header, but only for explicitly trusted proxy addresses:
# Trust only the private load-balancer subnet you control.
set_real_ip_from 10.20.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
With recursive processing enabled, NGINX walks the forwarded chain and selects the last non-trusted address. The important security boundary is set_real_ip_from: never trust X-Forwarded-For from the entire internet. A direct client can forge that header and rotate fake addresses to bypass the limiter.
Check that your build includes the module:
nginx -V 2>&1 | grep -- --with-http_realip_module
Many distribution packages include it, but the official documentation notes that it is not built by default when compiling NGINX from source. If your provider uses a different authenticated header or PROXY protocol, follow that provider's documented trust model and maintain its current proxy address ranges.
After configuring real-IP handling, $remote_addr becomes the selected client address and $realip_remote_addr retains the original network peer. Logging both makes trust mistakes easier to diagnose.
Roll out in dry-run mode first
A limiter can count excessive requests without enforcing the decision:
location /api/ {
limit_req zone=api_per_ip burst=20 nodelay;
limit_req_dry_run on;
proxy_pass http://app_backend;
}
Dry-run mode is ideal for measuring how a proposed policy affects real traffic. NGINX records outcomes in $limit_req_status, whose possible values include PASSED, DELAYED, REJECTED, and dry-run variants.
Add the result to a dedicated access format:
log_format api_limit escape=json
'{"time":"$time_iso8601",'
'"client":"$remote_addr",'
'"peer":"$realip_remote_addr",'
'"request":"$request",'
'"status":$status,'
'"limit":"$limit_req_status",'
'"request_time":$request_time}';
access_log /var/log/nginx/api-limit.log api_limit;
Observe peak periods, mobile clients, office NATs, health checks, and automated integrations. A policy that looks safe during quiet hours may punish legitimate morning traffic. When the dry-run data is acceptable, remove limit_req_dry_run on, validate the configuration, and reload.
Validate and reload without dropping traffic
Always test syntax before applying a change:
sudo nginx -t
sudo systemctl reload nginx
A reload asks the master process to start workers with the new configuration while old workers finish active requests. If nginx -t fails, do not reload. Read the exact file and line number in the error, then correct the configuration.
Confirm that the effective configuration contains the expected zones and locations:
sudo nginx -T 2>&1 | grep -E 'limit_req|real_ip|api_limit'
nginx -T can expose secrets embedded in configuration, so run it only in an appropriate administrative environment and do not paste its full output into public tickets.
Test the policy deliberately
First verify a normal request:
curl -i https://api.example.com/api/health
Then send a small concurrent burst from a test address:
seq 1 40 | xargs -P 40 -I{} \
curl -sS -o /dev/null -w '%{http_code}\n' \
https://api.example.com/api/test
Expect a mix determined by the configured rate, burst allowance, request timing, and whether dry-run mode is active. Do not run an aggressive test against production without permission and capacity planning. A staging environment with the same NGINX policy is safer.
Inspect the log outcomes:
jq -r '.limit' /var/log/nginx/api-limit.log | sort | uniq -c
Test from both direct and proxied paths. Verify that two known client addresses appear separately. If every request shares one address, fix real-IP handling before enabling enforcement.
Use multiple limits when resources differ
NGINX can apply more than one limit_req directive. For example, combine a per-client policy with a wider server-level safety ceiling:
limit_req_zone $binary_remote_addr zone=per_client:10m rate=10r/s;
limit_req_zone $server_name zone=per_server:10m rate=500r/s;
location /api/ {
limit_req zone=per_client burst=20 nodelay;
limit_req zone=per_server burst=100;
limit_req_status 429;
proxy_pass http://app_backend;
}
The client limit reduces individual abuse, while the server limit protects total upstream capacity. Remember that directives are inherited from an outer configuration level only when the current level has no limit_req directives. Review the rendered configuration rather than assuming a parent rule still applies.
Troubleshooting common failures
Every user is rate-limited together
NGINX is probably seeing a load balancer address as $remote_addr. Configure the real-IP module using only trusted proxy CIDRs, then log both the selected client and original peer.
The limit appears to do nothing
Check for limit_req_dry_run on, confirm the request reaches the location you edited, and inspect $limit_req_status. An exact-match or regex location may win over the block you expected.
Legitimate clients receive too many 429 responses
Review shared NAT traffic and client retry behavior. Increase the burst carefully, choose a less aggressive average, or move authenticated quotas into the application. Do not simply multiply every threshold without understanding upstream capacity.
Requests become slow instead of failing
Excess requests within the burst allowance are delayed by default. Add nodelay when immediate processing is appropriate, or use the delay parameter to allow part of the burst without delay.
NGINX reports an unknown directive
Inspect nginx -V. Your build may omit the required module, or your installed version may be older than a directive such as limit_req_dry_run. Use a maintained distribution or official package rather than silently deleting an important control.
The application still receives bursts it cannot handle
A burst is permission for temporary concurrency. Reduce it, allow delayed processing, add a server-wide limit, or improve upstream capacity. Monitor upstream latency and errors alongside limiter outcomes.
Production checklist
- Identify the resource and actor each limiter protects.
- Use
$binary_remote_addrfor compact IP-based keys. - Configure real client IPs only from trusted proxies.
- Select average rates from measured capacity and legitimate usage.
- Set burst allowances intentionally and understand
nodelay. - Return HTTP 429 for rejected API requests.
- Run in dry-run mode before enforcement.
- Log
$limit_req_status, client address, peer address, and latency. - Test direct, proxied, IPv4, IPv6, and shared-NAT traffic.
- Run
nginx -tbefore every reload. - Monitor 429 rates, upstream latency, and application errors.
- Keep application-level identity quotas and abuse controls.
- Document exceptions for trusted internal services rather than creating broad bypasses.