Rate limiting caps how many requests a client can send in a period of time, which protects an API from brute force attempts, scrapers and clients stuck in retry loops. In this tutorial you will first add per-IP limits with the limit_req module that ships with Nginx, which is enough for a single gateway. Then you will replace Nginx with OpenResty and keep the counters in Redis, so that several gateways share the same limits and each API key can have its own quota. Everything runs on Ubuntu 24.04.
Prerequisites
To follow this tutorial, you will need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, and a non-root user with
sudoprivileges. - An API listening on
127.0.0.1:3000. If you do not have one yet, Step 1 starts a throwaway test backend. - A hostname for the API such as
api.your_domain. Replace it with your own everywhere. DNS is not needed for the tests, which send theHostheader explicitly.
Part 1 (Steps 1 to 4) needs only Nginx. Part 2 (Steps 5 to 9) is for setups with more than one gateway or with per-client quotas.
Step 1 - Installing Nginx and a test backend
Install Nginx and open HTTP and HTTPS in the firewall:
sudo apt update
sudo apt install -y nginx
sudo ufw allow 'Nginx Full'
If you do not have an API yet, create a few static files and serve them with Python's built-in HTTP server on port 3000. It stands in for your application so you can see which requests get through:
mkdir -p ~/api-test/api/auth
echo ok > ~/api-test/api/index.html
echo ok > ~/api-test/api/auth/login
python3 -m http.server 3000 --bind 127.0.0.1 --directory ~/api-test > /dev/null 2>&1 &
Check that it answers:
curl -s http://127.0.0.1:3000/api/
ok
Step 2 - Defining rate limit zones
Nginx implements rate limiting with a leaky bucket. A limit_req_zone defines three things: the key that identifies a client, a shared memory zone that stores the state for each key, and the sustained rate. A 10 MB zone holds about 160,000 IP addresses.
On Ubuntu, files in /etc/nginx/conf.d/ are included inside the http block, which is where zones must be declared. Create a file for them:
sudo nano /etc/nginx/conf.d/rate-limits.conf
# Clients that are never rate limited (monitoring, internal networks)
geo $rate_limit_exempt {
default 0;
192.0.2.10 1;
10.0.0.0/8 1;
}
# An empty key disables limiting for that request
map $rate_limit_exempt $api_limit_key {
0 $binary_remote_addr;
1 "";
}
# General API traffic: 10 requests per second per IP
limit_req_zone $api_limit_key zone=api_per_ip:10m rate=10r/s;
# Login endpoint: 5 requests per minute per IP
limit_req_zone $binary_remote_addr zone=login_per_ip:10m rate=5r/m;
# Answer with 429 instead of the default 503, and log rejections as warnings
limit_req_status 429;
limit_req_log_level warn;
Replace 192.0.2.10 and 10.0.0.0/8 with the addresses you want to exempt, or remove those lines. Requests whose key is an empty string are not counted, which is how the geo and map pair exempts trusted clients. $binary_remote_addr is the client IP in binary form, which uses less memory than the text form.
Step 3 - Applying the limits to the API
Zones do nothing until a limit_req directive uses them. Create a server block for the API:
sudo nano /etc/nginx/sites-available/api
server {
listen 80;
listen [::]:80;
server_name api.your_domain;
# Return a JSON body for rejected requests
error_page 429 @rate_limited;
location /api/ {
limit_req zone=api_per_ip burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location = /api/auth/login {
limit_req zone=login_per_ip burst=3 nodelay;
limit_req zone=api_per_ip burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location @rate_limited {
default_type application/json;
add_header Retry-After 1 always;
return 429 '{"detail": "Too many requests"}\n';
}
}
The burst and nodelay parameters decide what happens above the rate:
| Setting | Behavior |
|---|---|
No burst | Any request that arrives faster than the rate is rejected immediately. Too strict for real clients, which send requests in small bursts. |
burst=20 | Up to 20 extra requests are queued and released at the configured rate. Clients see added latency. |
burst=20 nodelay | Up to 20 extra requests are served immediately, and the burst slots refill at the configured rate. Requests beyond that get a 429. This is usually what you want for an API. |
When a location has several limit_req lines, all of them apply and the most restrictive one wins, so the login endpoint is limited by both zones.
Enable the site, test the configuration and reload Nginx:
sudo ln -s /etc/nginx/sites-available/api /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
TipTo try new limits on live traffic without rejecting anyone, add
limit_req_dry_run on;to the location. Nginx then only logs the requests it would have rejected.
Step 4 - Testing the limits
Send 30 requests as fast as possible and count the status codes:
for i in $(seq 1 30); do
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: api.your_domain" http://127.0.0.1/api/
done | sort | uniq -c
22 200
8 429
One request uses the rate, 20 use the burst, and one or two more pass because the bucket refills while the loop runs. The rest are rejected. Look at a rejected response:
curl -si -H "Host: api.your_domain" http://127.0.0.1/api/ | head -n 8
If you run it right after the loop you get the JSON body from the named location:
HTTP/1.1 429 Too Many Requests
Server: nginx/1.24.0 (Ubuntu)
Content-Type: application/json
Content-Length: 32
Connection: keep-alive
Retry-After: 1
{"detail": "Too many requests"}
Wait a few seconds for the general zone to refill, then test the login endpoint, which allows one request plus a burst of three:
sleep 5
for i in $(seq 1 8); do
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: api.your_domain" http://127.0.0.1/api/auth/login
done | sort | uniq -c
4 200
4 429
Every rejection is logged with the zone that triggered it, which tells you which limit to tune:
sudo grep "limiting requests" /var/log/nginx/error.log | tail -n 2
2026/09/25 10:14:03 [warn] 2211#2211: *118 limiting requests, excess: 3.990 by zone "login_per_ip", client: 127.0.0.1, server: api.your_domain, request: "GET /api/auth/login HTTP/1.1", host: "api.your_domain"
ImportantIf Nginx sits behind a load balancer or CDN,
$binary_remote_addris the address of that proxy and all your users share one bucket. Use therealipmodule (set_real_ip_fromwith the proxy's address range andreal_ip_header X-Forwarded-For;) so Nginx sees the real client IP before limiting.
Once the limits behave as expected, add HTTPS with sudo apt install python3-certbot-nginx and sudo certbot --nginx -d api.your_domain.
Step 5 - Installing Redis
Nginx keeps its counters in the memory of each server. With two gateways behind a load balancer, a client gets twice the limit, and a restart resets everything. Storing the counters in Redis fixes both problems. Install it:
sudo apt install -y redis-server
Check that the service is running:
redis-cli ping
PONG
The Ubuntu package listens on 127.0.0.1 only. When several gateways share one Redis server, set bind in /etc/redis/redis.conf to its private IP, set a requirepass, allow port 6379 in UFW only from the gateways' private addresses, and restart Redis with sudo systemctl restart redis-server. Never expose Redis to the Internet.
Step 6 - Installing OpenResty
OpenResty is Nginx bundled with LuaJIT and a set of Lua libraries, including the lua-resty-redis client used below. It uses the same configuration syntax as Nginx but ships its own binary and service, and both want port 80, so stop and disable the stock Nginx first:
sudo systemctl disable --now nginx
Add the official OpenResty repository with its signing key:
sudo apt install -y --no-install-recommends wget gnupg ca-certificates lsb-release
sudo install -m 0755 -d /etc/apt/keyrings
wget -qO - https://openresty.org/package/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/openresty.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/openresty.gpg] http://openresty.org/package/ubuntu $(lsb_release -sc) main" \
| sudo tee /etc/apt/sources.list.d/openresty.list > /dev/null
On an arm64 server, use arch=arm64 and http://openresty.org/package/arm64/ubuntu instead. Install the package:
sudo apt update
sudo apt install -y openresty
Verify the installation and the service:
openresty -v
systemctl is-active openresty
nginx version: openresty/1.27.1.2
active
The configuration lives in /usr/local/openresty/nginx/conf/. Unlike Ubuntu's Nginx, the main nginx.conf has no conf.d include. Open it:
sudo nano /usr/local/openresty/nginx/conf/nginx.conf
Add this line inside the http { ... } block, just before its closing brace. Relative paths are resolved against the configuration directory:
include conf.d/*.conf;
Then create the directories for your site files and Lua code:
sudo mkdir -p /usr/local/openresty/nginx/conf/conf.d /usr/local/openresty/nginx/conf/lua
Step 7 - Writing the Redis rate limiter
The limiter uses a fixed window counter: every client gets a Redis key per scope, INCR counts each request, and the key expires at the end of the window. The increment and the expiry run inside a small Redis Lua script, so they are atomic even when several gateways hit the same key at once.
Clients are identified in two ways:
- Requests with an
X-API-Keyheader must use a key that exists in Redis. Its hash holds the quota for that client, so different customers can have different limits. - Requests without a key are counted per IP address with the defaults set in the Nginx location.
Create the script:
sudo nano /usr/local/openresty/nginx/conf/lua/rate_limit.lua
local redis = require "resty.redis"
local REDIS_HOST = "127.0.0.1"
local REDIS_PORT = 6379
local REDIS_PASSWORD = nil -- set to your requirepass value if Redis uses one
-- Increment the counter and set its expiry in a single atomic step
local INCR_SCRIPT = [[
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return {current, redis.call("TTL", KEYS[1])}
]]
local function reject(status, body, red)
if red then
red:set_keepalive(10000, 100)
end
ngx.status = status
ngx.header["Content-Type"] = "application/json"
ngx.say(body)
return ngx.exit(status)
end
local scope = ngx.var.rl_scope or "default"
local limit = tonumber(ngx.var.rl_limit) or 100
local window = tonumber(ngx.var.rl_window) or 60
local red = redis:new()
red:set_timeouts(100, 100, 100) -- connect, send, read in milliseconds
local ok, err = red:connect(REDIS_HOST, REDIS_PORT)
if not ok then
-- Fail open: an outage in Redis must not take the API down
ngx.log(ngx.ERR, "rate limit: cannot connect to Redis: ", err)
return
end
if REDIS_PASSWORD then
local ok, err = red:auth(REDIS_PASSWORD)
if not ok then
ngx.log(ngx.ERR, "rate limit: Redis auth failed: ", err)
red:close()
return
end
end
local client
local api_key = ngx.var.http_x_api_key
if api_key and api_key ~= "" then
local plan, err = red:hmget("apikey:" .. api_key, "limit", "window")
if not plan then
ngx.log(ngx.ERR, "rate limit: HMGET failed: ", err)
red:set_keepalive(10000, 100)
return
end
if plan[1] == ngx.null then
return reject(401, '{"detail": "Invalid API key"}', red)
end
limit = tonumber(plan[1]) or limit
if plan[2] ~= ngx.null then
window = tonumber(plan[2]) or window
end
client = "key:" .. api_key
else
client = "ip:" .. ngx.var.remote_addr
end
local res, err = red:eval(INCR_SCRIPT, 1, "rl:" .. scope .. ":" .. client, window)
if not res then
ngx.log(ngx.ERR, "rate limit: EVAL failed: ", err)
red:set_keepalive(10000, 100)
return
end
local count, ttl = tonumber(res[1]), tonumber(res[2])
if ttl < 0 then
ttl = window
end
-- Read by header_filter_by_lua_block to add X-RateLimit-* headers
ngx.ctx.ratelimit = {
limit = limit,
remaining = math.max(0, limit - count),
reset = ttl,
}
if count > limit then
ngx.header["Retry-After"] = ttl
return reject(429, '{"detail": "Too many requests"}', red)
end
red:set_keepalive(10000, 100)
set_keepalive returns the connection to a per-worker pool instead of closing it, so each request does not open a new TCP connection to Redis. The script fails open when Redis is unreachable, which favors availability. If protecting the backend matters more than availability, replace the bare return after the connection error with return reject(503, '{"detail": "Service unavailable"}').
Step 8 - Configuring the API server in OpenResty
Each location sets its scope, limit and window with set, then runs the script in the access phase, before the request is proxied. The header_filter_by_lua_block adds the rate limit headers to every response, including the 429 answers generated by the script. Create the site file:
sudo nano /usr/local/openresty/nginx/conf/conf.d/api.conf
upstream api_backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name api.your_domain;
header_filter_by_lua_block {
local rl = ngx.ctx.ratelimit
if rl then
ngx.header["X-RateLimit-Limit"] = rl.limit
ngx.header["X-RateLimit-Remaining"] = rl.remaining
ngx.header["X-RateLimit-Reset"] = rl.reset
end
}
location /api/ {
set $rl_scope api;
set $rl_limit 100;
set $rl_window 60;
access_by_lua_file /usr/local/openresty/nginx/conf/lua/rate_limit.lua;
proxy_http_version 1.1;
proxy_set_header Connection "";
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://api_backend;
}
location = /api/auth/login {
set $rl_scope login;
set $rl_limit 5;
set $rl_window 60;
access_by_lua_file /usr/local/openresty/nginx/conf/lua/rate_limit.lua;
proxy_http_version 1.1;
proxy_set_header Connection "";
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://api_backend;
}
}
Test the configuration and reload OpenResty:
sudo openresty -t
sudo systemctl reload openresty
nginx: the configuration file /usr/local/openresty/nginx/conf/nginx.conf syntax is ok
nginx: configuration file /usr/local/openresty/nginx/conf/nginx.conf test is successful
Step 9 - Testing distributed limits and API key quotas
Call the login endpoint eight times. With a limit of 5 per 60 seconds, the last three are rejected:
for i in $(seq 1 8); do
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: api.your_domain" http://127.0.0.1/api/auth/login
done | sort | uniq -c
5 200
3 429
Inspect the headers of a normal API call:
curl -si -H "Host: api.your_domain" http://127.0.0.1/api/ | grep -i ratelimit
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 60
The counters are now in Redis, where every gateway sees them. List them with their remaining lifetime:
redis-cli --scan --pattern 'rl:*'
redis-cli ttl rl:login:ip:127.0.0.1
rl:api:ip:127.0.0.1
rl:login:ip:127.0.0.1
(integer) 41
Now create two API keys with different quotas. Each is a Redis hash with a request limit and a window in seconds. Use long random values in production, for example the output of openssl rand -hex 24:
redis-cli HSET apikey:free-7f3a9c limit 1000 window 3600
redis-cli HSET apikey:pro-b81d42 limit 100000 window 3600
A request with the pro key is counted against that key's quota instead of the caller's IP:
curl -si -H "Host: api.your_domain" -H "X-API-Key: pro-b81d42" http://127.0.0.1/api/ | grep -i ratelimit
X-RateLimit-Limit: 100000
X-RateLimit-Remaining: 99999
X-RateLimit-Reset: 3600
An unknown key is refused before it reaches the backend:
curl -s -H "Host: api.your_domain" -H "X-API-Key: wrong" http://127.0.0.1/api/
{"detail": "Invalid API key"}
To change a customer's quota, update the hash with HSET. To revoke a key, delete it with redis-cli DEL apikey:free-7f3a9c. Neither change needs a reload.
When you are done testing, stop the Python backend with kill %1 in the shell where you started it, or with pkill -f "http.server 3000".
Troubleshooting
Legitimate users receive 429 with Nginx limit_req: find the zone in the error log (grep "limiting requests" /var/log/nginx/error.log) and raise its burst before raising the rate. Check also that you are not limiting a proxy address instead of real clients, as described in Step 4.
OpenResty never limits and the error log shows cannot connect to Redis: the script is failing open. Check redis-cli ping on the gateway, the bind address and firewall on the Redis server, and the password in REDIS_PASSWORD. OpenResty logs to /usr/local/openresty/nginx/logs/error.log.
attempt to compare nil with number or similar Lua errors: a location is missing one of the set $rl_* lines, or an API key hash has a non-numeric limit. Check the hash with redis-cli HGETALL apikey:<key>.
bind() to 0.0.0.0:80 failed (98: Address already in use) when starting OpenResty: the stock Nginx is still running. Run sudo systemctl disable --now nginx and start OpenResty again.
Conclusion
You limited API traffic per IP with Nginx's built-in limit_req module, returned clean 429 responses, and then moved the counters to Redis with OpenResty so that several gateways share the same limits and each API key has its own quota. Next, you can put the gateways behind a load balancer pointing at the same Redis server, add HTTPS with Certbot, and alert on the rate of 429 responses in your access logs to catch misbehaving clients early.
