Nginx Rate Limiting Strategies for Public APIs Behind a Reverse Proxy
Quick answer
Every public API eventually meets a client that sends far too many requests, whether it is a buggy retry loop, a scraper, or an abusive script. Rate limiting...
Every public API eventually meets a client that sends far too many requests, whether it is a buggy retry loop, a scraper, or an abusive script. Rate limiting at the reverse proxy is an inexpensive first line of defense because it rejects excess traffic before your application spends any resources on it. Nginx ships with rate limiting built in, but the default behavior surprises many people, and a few configuration mistakes make the limits useless.
How limit_req Works
Nginx implements request rate limiting with a leaky bucket model through two directives. limit_req_zone defines a shared memory zone, a key, and a rate. limit_req applies that zone to a location. Requests that arrive faster than the configured rate are delayed or rejected.
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://app_backend;
}
}
}
The zone size matters because the key state is stored in memory. The binary form of the client address is compact, and a 10 megabyte zone can hold on the order of many thousands of addresses, but check the documentation for exact numbers for your version. If the zone fills up, Nginx evicts old entries, and if it cannot, it rejects requests, so size the zone for your expected number of distinct clients.
Without a burst, a client that sends two requests in the same millisecond at a 10 requests per second limit gets the second one rejected, because the rate is enforced in small intervals. That is rarely what you want for real clients, which send short bursts, so burst gives a queue of extra requests allowed above the rate.
Burst, nodelay, and delay
These parameters decide what happens to requests within the burst allowance. By default, burst requests are queued and released at the configured rate, which smooths traffic but adds latency to those requests. With nodelay, burst requests are served immediately, and the burst slots refill at the configured rate. This is usually better for APIs because users do not wait, while sustained abuse still gets rejected.
A two-stage option exists as well: delay lets you serve a first portion of the burst immediately and delay the rest, with anything beyond the burst rejected. This is available in newer Nginx versions, so confirm support in the documentation for your release.
- Use nodelay when clients expect low latency and some bursts are normal.
- Omit nodelay when you want to smooth out spikes to protect a fragile backend.
- Pick burst values from observed legitimate traffic, not guesses. A burst of 20 on a 10 per second limit means a client can send 21 requests at once and then must slow down.
Return a Proper Status Code
By default, rejected requests receive a 503 response, which looks like a server failure and can trigger your monitoring and client retry logic in unhelpful ways. Setting limit_req_status to 429 tells clients they are sending too many requests. Pair it with a Retry-After header when practical, so well-behaved clients know when to try again. Also adjust limit_req_log_level to avoid flooding error logs during an attack.
Get the Client IP Right Behind Proxies
This is the most common mistake. If Nginx sits behind a load balancer or CDN, $remote_addr is the address of that proxy, not the user. Every client then shares one bucket, and you either block everyone or, with generous limits, no one. The fix is to tell Nginx which proxies to trust and which header carries the real address, using the real_ip module:
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Only trust headers from proxies you control. If you trust X-Forwarded-For from arbitrary sources, a client can forge it and rotate fake addresses to evade the limit. Check the real_ip documentation for how the header is parsed and which proxy ranges your provider publishes.
Limit by Something Better Than IP
IP-based limits are crude. Many users share one address behind corporate or mobile carrier networks, and attackers can use many addresses. For authenticated APIs, limit by API key or token, and keep an IP limit as a coarse backstop. You can build the key with a map, falling back to the IP when no key exists:
map $http_x_api_key $limit_key {
default $http_x_api_key;
"" $binary_remote_addr;
}
limit_req_zone $limit_key zone=perkey:20m rate=30r/s;
Different endpoints deserve different limits. Login and password reset routes should be much stricter than read endpoints. Define separate zones and apply them per location. Remember that limit_conn restricts concurrent connections and complements request rate limits for slow or long-lived requests.
Know What Nginx Rate Limiting Cannot Do
The counters live in each Nginx instance. If you run several instances behind a load balancer, each enforces its own limit, so the effective global limit is roughly multiplied by the number of nodes. For strict, shared quotas or per-customer billing limits, enforce in the application or a shared store such as a distributed cache. Nginx limiting is best seen as protection against overload and abuse rather than precise quota accounting.
Frequently Asked Questions
Does rate limiting stop a DDoS attack?
Not by itself. It helps against single-source abuse, but large distributed attacks need network-level and upstream protection beyond a single server's capacity.
Why are all my users getting 429 or 503 at once?
Likely your key is the proxy's address because real client IPs are not configured. Set up the real_ip directives with trusted proxy ranges.
Can I test limits without blocking traffic?
Recent Nginx versions offer a dry run mode for limit_req that logs what would be rejected without enforcing it. Verify availability for your version in the documentation.
Should I rate limit static files?
Usually apply limits to dynamic or expensive endpoints. Static assets are cheap to serve, especially with caching or a CDN in front.
Summary
Use limit_req_zone and limit_req with a sensible burst, usually with nodelay for APIs, return 429 instead of the default 503, and make sure Nginx sees real client addresses behind proxies. Prefer API keys over raw IPs where possible, use stricter limits on sensitive endpoints, and enforce precise quotas in a shared layer. Always confirm directive behavior against the official Nginx documentation for your version.