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 sudo privileges.
  • 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 the Host header 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:

SettingBehavior
No burstAny request that arrives faster than the rate is rejected immediately. Too strict for real clients, which send requests in small bursts.
burst=20Up to 20 extra requests are queued and released at the configured rate. Clients see added latency.
burst=20 nodelayUp 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

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"

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-Key header 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.