GICT knowledge base
How to Configure Rate Limiting in Nginx
Published
A busy endpoint can spend most of its time serving the same client. Nginx can limit requests before they reach that endpoint, allowing a short burst while rejecting further excess traffic. The setting belongs on the route that needs it, not automatically on every image, stylesheet, and page.
This guide uses Nginx’s built-in request limiter on Ubuntu 24.04. By the end of this article, You will have an hands-on experience on implementing Rate limiting in Nginx.
Prepare a private test endpoint
Use an Ubuntu VPS with sudo access. This example needs no domain, public port, paid module, or separate application. It serves a small static file on 127.0.0.1:8088, leaving existing sites in place.
Install Nginx and the test tools:
$ sudo apt-get update
$ sudo apt-get install -y nginx curl python3
$ nginx -v
$ sudo nginx -T
$ sudo ss -ltnp
Inspect the configuration before editing. On Ubuntu’s package, /etc/nginx/conf.d/*.conf is included inside http {}. Confirm that include and check that port 8088 and the names below are unused. If you already have Nginx, preserve its configuration; do not replace nginx.conf or disable working sites. On a fresh installation, the package also creates a default website; this guide does not change its exposure.
Create the test directory:
$ sudo install -d -m 0755 /var/www/kb-rate-limit
Create /var/www/kb-rate-limit/probe.txt as root, with these contents:
rate-limit-ok
Give the worker permission to read it:
$ sudo chmod 0644 /var/www/kb-rate-limit/probe.txt
Define the zone and dry-run policy
Create /etc/nginx/conf.d/kb-rate-limit.conf as root. This is a new file; do not overwrite an existing file with that name.
limit_req_zone $binary_remote_addr zone=kb_per_ip:10m rate=2r/s;
log_format kb_limit '$remote_addr "$request" $status $limit_req_status';
server {
listen 127.0.0.1:8088;
server_name localhost;
root /var/www/kb-rate-limit;
access_log /var/log/nginx/kb-rate-limit.access.log kb_limit;
location = /probe.txt {
limit_req zone=kb_per_ip burst=3 nodelay;
limit_req_status 429;
limit_req_dry_run on;
}
}
limit_req_zone belongs in the HTTP context, outside the server block. It allocates a shared 10 MiB state zone keyed by the binary client IP address. Workers share this zone; each worker does not receive an independent allowance.
The location activates the limit for exactly /probe.txt. Query strings do not change that location match. Other paths in this demonstration server do not use the limiter.
| Setting | Meaning |
|---|---|
rate=2r/s |
Excess debt drains at two requests per second. |
burst=3 |
Allow up to three excess requests beyond the initial request. |
nodelay |
Forward allowed burst requests immediately rather than spacing them out. |
limit_req_status 429 |
Reject excess requests with 429 instead of the default 503. |
limit_req_dry_run on |
Record what would be limited without enforcing rejection. |
nodelay does not remove the rate limit. Burst requests still occupy allowance until it drains. This is not a counter that resets at each wall-clock second.
Check syntax, reload, and confirm the endpoint:
$ sudo nginx -t
$ sudo systemctl reload nginx
$ curl -fsS http://127.0.0.1:8088/probe.txt
The response was:
rate-limit-ok
A connection failure or 404 is not evidence that rate limiting works; fix the listener or file path first.
Send a bounded burst
Create ~/kb-rate-test.py as your normal user:
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from time import perf_counter
from urllib.error import HTTPError
from urllib.request import urlopen
URL = "http://127.0.0.1:8088/probe.txt"
def request(_):
try:
with urlopen(URL, timeout=5) as response:
response.read()
return response.status
except HTTPError as error:
code = error.code
error.close()
return code
start = perf_counter()
with ThreadPoolExecutor(max_workers=10) as workers:
counts = Counter(workers.map(request, range(10)))
for code, count in sorted(counts.items()):
print(f"HTTP {code}: {count}")
print(f"Elapsed: {perf_counter() - start:.3f} s")
The script sends only ten requests to your own test endpoint. Run:
$ python3 ~/kb-rate-test.py
$ sudo tail -n 10 /var/log/nginx/kb-rate-limit.access.log
The test returned ten HTTP 200 responses in 0.366 seconds. Dry-run requests should receive 200. The log must also contain REJECTED_DRY_RUN decisions: those identify requests that enforcement would reject. Ten successful responses alone do not prove the policy was evaluated.
Enforce the limit and check recovery
In /etc/nginx/conf.d/kb-rate-limit.conf, change only the dry-run line to:
limit_req_dry_run off;
Apply the edit:
$ sudo nginx -t
$ sudo systemctl reload nginx
Wait three seconds for the previous burst to drain, then repeat:
$ python3 ~/kb-rate-test.py
The measured result was:
HTTP 200: 4
HTTP 429: 6
Elapsed: 0.356 s
Inspect the corresponding decisions:
$ sudo tail -n 10 /var/log/nginx/kb-rate-limit.access.log
A fast burst against an idle key should accept the first request plus three burst requests and reject the remaining six. Timing matters: if a slow client spreads the requests out, more allowance can become available during the test. Check the log for PASSED alongside 200 and REJECTED alongside 429, not just a particular total.
Wait another three seconds and check again:
$ curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8088/probe.txt
The recovery request returned:
200
The rejection is temporary; Nginx has not banned that address. This configuration does not automatically send a Retry-After header, and it limits request rate rather than simultaneous connections.
Apply the policy to a real route
After the local test works, reuse the zone in the existing location that actually handles your chosen application route. Merge the limit_req, limit_req_status, and dry-run directives into that location while preserving its proxy_pass, fastcgi_pass, authentication, headers, and other routing settings. Do not create a duplicate location or replace a dynamic handler with this static-file example.
Start the real route in dry-run mode and use the dedicated log format to assess normal traffic. It records request URLs, including query strings, so avoid sensitive tokens in those URLs. Choose the rate and burst from observed usage, then enforce after checking legitimate requests. The demonstration values are deliberately low, not a universal production recommendation. Test through the real hostname and HTTPS path as well; the loopback exercise does not verify your public virtual host.
If every request still succeeds
- Check dry-run and the log.
REJECTED_DRY_RUNis an observation, not an enforced 429. Confirm the effective configuration withsudo nginx -Tand reload after editing. - Avoid
return 200as the test handler. Rewrite-phase returns can complete a request before the limiter runs. Serve a real file or use the existing content handler. - Check which location wins. Limits in one location do not automatically cover a different matching location. A child scope with its own
limit_reqalso replaces inheritedlimit_reqdirectives rather than adding to them. - Distinguish 404 from 429. A file-path failure and a limiter rejection have different causes. The limiter status field identifies whether a decision occurred;
-means no value was recorded.
When per-IP limits are not enough
An office, mobile network, or VPN can put many legitimate users behind one address. A distributed client can use many addresses. Per-IP limiting cannot enforce an account’s paid quota or prevent a volumetric attack from filling the VPS network link. Keep account-level quotas in the application, and use upstream protection for traffic that must be stopped before reaching the server.
For one noisy endpoint, however, this gives you a testable policy: observe excess requests first, return 429 when enforcement is enabled, and confirm that ordinary requests recover after the allowance drains. In this test, a ten-request burst produced four 200 responses and six 429 responses in 0.356 seconds.
Related guides
How to Enable Brotli Compression in Nginx on Ubuntu 24.04
Enable Brotli in Ubuntu Nginx, preserve gzip fallback, verify response headers and avoid stale precompressed assets. Measure savings on your own files.
nginxSecuring Nginx with SSL/TLS on Ubuntu: Advanced Tips and Tricks
Secure your Ubuntu server with free SSL/TLS certificates using Let's Encrypt and Nginx.
nginxHow To Configure Nginx as a Reverse Proxy on Ubuntu 24.04
Learn how to set up Nginx as a reverse proxy on Ubuntu 24.04 with this step-by-step guide.
nginxHow to Effectively Use Nginx as a Load Balancer in Ubuntu 24.04
Learn to configure Nginx load balancing on Ubuntu 24.04. Complete guide with setup, algorithms, health checks, SSL termination & troubleshooting tips.
