Limitar la tasa de peticiones protege una API frente a abusos, scrapers y picos que tumbarían el backend, y reparte la capacidad de forma justa entre clientes. En este tutorial configurarás en Ubuntu 24.04 el módulo limit_req de Nginx para limitar por IP, aplicar límites más estrictos al login y dar un cupo mayor a los clientes con API key válida, con respuestas 429 en JSON y un modo de prueba para ajustar los valores con tráfico real. Después verás cómo compartir los contadores entre varios servidores con OpenResty y Redis.
Requisitos previos
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath.
- Un usuario no root con privilegios
sudo. - Nginx instalado (
sudo apt install nginx) y el puerto 80 permitido en el firewall. - Tu API escuchando en
127.0.0.1:8000. Si todavía no la tienes, el paso 1 crea un backend de prueba. - Para la parte con Redis (pasos 6 a 8): uno o varios servidores que actúen como proxy de la misma API.
Cómo funciona limit_req
limit_req usa el algoritmo leaky bucket: cada clave (una IP, una API key) tiene un cubo que se vacía a la velocidad rate. Las peticiones que llegan cuando el cubo está lleno se rechazan. Tres parámetros controlan el comportamiento:
| Parámetro | Dónde | Qué hace |
|---|---|---|
rate | limit_req_zone | Velocidad sostenida permitida, en peticiones por segundo (r/s) o por minuto (r/m) |
burst | limit_req | Cuántas peticiones por encima de rate se aceptan de golpe antes de rechazar |
nodelay | limit_req | Atiende la ráfaga al momento en lugar de espaciarla a la velocidad rate |
Sin nodelay, Nginx retrasa las peticiones de la ráfaga para ajustarlas a rate, lo que añade latencia. Para una API suele ser preferible nodelay: el cliente recibe respuesta inmediata o un 429.
Los contadores viven en memoria compartida de cada servidor Nginx. Una zona de 1 MB guarda unos 16.000 estados de IP, así que 10 MB bastan para la mayoría de APIs.
Paso 1: Crear un backend de prueba
Para probar los límites necesitas un backend real detrás de proxy_pass: una directiva return en la misma location se ejecuta antes que limit_req y se saltaría el límite. Si ya tienes tu API en 127.0.0.1:8000, omite este paso. Si no, crea un servidor de prueba en Nginx que responda JSON:
sudo nano /etc/nginx/sites-available/backend-demo
server {
listen 127.0.0.1:8000;
location / {
default_type application/json;
return 200 '{"ok":true}\n';
}
}
sudo ln -s /etc/nginx/sites-available/backend-demo /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
curl -s http://127.0.0.1:8000/
{"ok":true}
Paso 2: Definir las zonas de limitación
Las zonas se declaran en el contexto http. En Ubuntu, los archivos de /etc/nginx/conf.d/ se incluyen en ese contexto, así que crea uno para toda la configuración de límites:
sudo nano /etc/nginx/conf.d/ratelimit.conf
# API keys válidas: devuelve el nombre del cliente o "" si la key no existe
map $http_x_api_key $api_client {
default "";
include /etc/nginx/api-keys.map;
}
# Clave para peticiones anónimas: la IP solo si no hay API key válida
map $api_client $limit_anon {
"" $binary_remote_addr;
default "";
}
limit_req_zone $limit_anon zone=api_anon:10m rate=5r/s;
limit_req_zone $api_client zone=api_clients:10m rate=50r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_status 429;
limit_req_log_level warn;
log_format ratelimit '$remote_addr [$time_local] "$request" $status '
'client=$api_client limit=$limit_req_status';
Qué hace cada parte:
- Las peticiones con una clave vacía no se cuentan en una zona. Por eso cada petición cae en una sola de las dos zonas de la API: si trae una API key válida,
$limit_anonqueda vacío y cuenta enapi_clients(50 r/s por cliente); si no,$api_clientqueda vacío y cuenta enapi_anon(5 r/s por IP). - La API key se compara con una lista de keys conocidas. Si usaras directamente
$http_x_api_keycomo clave, cualquiera podría saltarse el límite enviando una key inventada distinta en cada petición. $binary_remote_addrocupa 4 bytes (16 en IPv6), menos que la IP en texto, y ahorra memoria en la zona.limit_req_status 429sustituye el503que Nginx devuelve por defecto, que los clientes interpretarían como una caída del servicio.- La variable
$limit_req_statusdel formato de log indica el resultado de cada petición:PASSED,DELAYED,REJECTEDo sus variantes_DRY_RUN.
Crea el archivo de API keys, con una línea por cliente. Usa keys largas y aleatorias; puedes generarlas con openssl rand -hex 24:
sudo nano /etc/nginx/api-keys.map
"your_api_key_for_acme" "acme";
"your_api_key_for_globex" "globex";
sudo chmod 640 /etc/nginx/api-keys.map
sudo chown root:www-data /etc/nginx/api-keys.map
Paso 3: Aplicar los límites a la API
Crea el sitio que publica la API y aplica las zonas en cada location:
sudo nano /etc/nginx/sites-available/api
server {
listen 80;
listen [::]:80;
server_name your_domain;
access_log /var/log/nginx/api-ratelimit.log ratelimit;
# Respuesta 429 en JSON
error_page 429 = @ratelimited;
location @ratelimited {
default_type application/json;
add_header Retry-After 1 always;
return 429 '{"detail":"Too many requests"}\n';
}
location /api/ {
limit_req zone=api_anon burst=10 nodelay;
limit_req zone=api_clients burst=100 nodelay;
proxy_pass http://127.0.0.1:8000;
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/login {
limit_req zone=login burst=3 nodelay;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Nginx elige la location más específica, así que /api/login solo aplica la zona login (5 intentos por minuto por IP, con un margen de 3), que frena ataques de fuerza bruta contra las contraseñas. El resto de /api/ aplica las dos zonas de la API.
Activa el sitio y recarga Nginx:
sudo ln -s /etc/nginx/sites-available/api /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Paso 4: Probar los límites
Lanza 20 peticiones seguidas sin API key. Las 11 primeras pasan (1 más la ráfaga de 10) y el resto recibe 429:
for i in $(seq 1 20); do curl -s -o /dev/null -w '%{http_code} ' -H 'Host: your_domain' http://127.0.0.1/api/; done; echo
200 200 200 200 200 200 200 200 200 200 200 429 429 429 429 429 429 429 429 429
Repite con una API key válida. El límite de 50 r/s con ráfaga de 100 no se alcanza:
for i in $(seq 1 20); do curl -s -o /dev/null -w '%{http_code} ' -H 'Host: your_domain' -H 'X-API-Key: your_api_key_for_acme' http://127.0.0.1/api/; done; echo
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
Una key inventada no da acceso a ese cupo: cuenta como anónima y comparte el límite de la IP. Comprueba ahora el login y el cuerpo de la respuesta 429:
for i in $(seq 1 6); do curl -s -o /dev/null -w '%{http_code} ' -H 'Host: your_domain' http://127.0.0.1/api/login; done; echo
curl -si -H 'Host: your_domain' http://127.0.0.1/api/login
200 200 200 200 429 429
HTTP/1.1 429 Too Many Requests
Server: nginx/1.24.0 (Ubuntu)
Content-Type: application/json
Content-Length: 31
Connection: keep-alive
Retry-After: 1
{"detail":"Too many requests"}
Paso 5: Ajustar los valores con el modo de prueba y los logs
Poner límites demasiado bajos en producción corta a clientes legítimos. Antes de activarlos, puedes medirlos con limit_req_dry_run: Nginx calcula los límites y los registra, pero deja pasar todas las peticiones. Añádelo en la location que quieras evaluar:
location /api/ {
limit_req zone=api_anon burst=10 nodelay;
limit_req zone=api_clients burst=100 nodelay;
limit_req_dry_run on;
# ...
}
Recarga Nginx, deja pasar tráfico real durante unas horas y cuenta cuántas peticiones se habrían rechazado, y de qué IPs o clientes:
sudo grep -c 'limit=REJECTED_DRY_RUN' /var/log/nginx/api-ratelimit.log
sudo grep 'limit=REJECTED' /var/log/nginx/api-ratelimit.log | awk '{print $1, $(NF-1)}' | sort | uniq -c | sort -rn | head
37
25 198.51.100.23 client=
12 203.0.113.9 client=acme
Si un cliente legítimo aparece en la lista, sube su burst o el rate de su zona. Cuando los números te convenzan, quita limit_req_dry_run on; y recarga. En modo normal, el log de errores también registra cada rechazo con la zona que lo provocó:
sudo grep 'limiting requests' /var/log/nginx/error.log | tail -n 2
2026/09/25 10:20:14 [warn] 2211#2211: *1893 limiting requests, excess: 10.520 by zone "api_anon", client: 198.51.100.23, server: your_domain, request: "GET /api/items HTTP/1.1", host: "your_domain"
Importantesi hay un balanceador o CDN delante de Nginx, todas las peticiones llegan con su IP y compartirían el mismo límite. Configura el módulo
realip(set_real_ip_fromyreal_ip_header) con los rangos de ese proxy para que$binary_remote_addrsea la IP real del cliente.
Paso 6: Instalar OpenResty y Redis para límites compartidos
Las zonas de limit_req son locales a cada servidor. Si tu API está detrás de varios servidores Nginx, un cliente obtiene el límite multiplicado por el número de servidores. Para un límite global, los contadores deben estar en un almacén compartido como Redis, y consultarlo desde Nginx requiere Lua. OpenResty es una distribución de Nginx que incluye LuaJIT y el cliente lua-resty-redis.
OpenResty ocupa los mismos puertos que Nginx, así que en cada servidor proxy sustituye a Nginx. Detén y desactiva Nginx:
sudo systemctl disable --now nginx
Añade el repositorio oficial de OpenResty. En servidores ARM, usa https://openresty.org/package/arm64/ubuntu como URL:
sudo apt install ca-certificates curl gnupg
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://openresty.org/package/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/openresty.gpg
echo "deb [signed-by=/etc/apt/keyrings/openresty.gpg] https://openresty.org/package/ubuntu $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/openresty.list
sudo apt update
sudo apt install openresty
openresty -v
nginx version: openresty/1.31.1.1
En el servidor que alojará los contadores (puede ser uno de los proxies o una máquina aparte), instala Redis:
sudo apt install redis-server
Edita su configuración para exigir contraseña y, si los proxies están en otras máquinas, escuchar también en la IP privada:
sudo nano /etc/redis/redis.conf
bind 127.0.0.1 -::1 your_private_ip
requirepass your_redis_password
maxmemory 256mb
maxmemory-policy volatile-ttl
Todos los contadores tienen caducidad, así que volatile-ttl descarta primero los que están a punto de expirar si Redis llega al límite de memoria. Reinicia Redis, permite el acceso solo desde tu red privada y comprueba que responde:
sudo systemctl restart redis-server
sudo ufw allow from 10.0.0.0/24 to any port 6379 proto tcp
redis-cli -a 'your_redis_password' --no-auth-warning ping
PONG
Sustituye 10.0.0.0/24 por el rango de tu red privada. Nunca expongas Redis a Internet.
Paso 7: Escribir el limitador en Lua
El limitador usa una ventana fija: cada clave tiene un contador por ventana de window segundos, que se incrementa con INCR y caduca con EXPIRE. Ambas operaciones van en un script que Redis ejecuta de forma atómica, para que dos proxies no se pisen. Si Redis no responde, el limitador deja pasar la petición y lo registra en el log: es preferible perder la limitación unos segundos que tumbar la API.
Crea el directorio del módulo y el archivo:
sudo mkdir -p /usr/local/openresty/nginx/conf/lua
sudo nano /usr/local/openresty/nginx/conf/lua/ratelimit.lua
local redis = require "resty.redis"
local _M = {}
-- INCR y EXPIRE atómicos: el contador vive exactamente una ventana
local SCRIPT = [[
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return current
]]
function _M.limit(opts)
local id = ngx.var.http_x_api_key
if not id or id == "" then
id = ngx.var.remote_addr
end
local now = ngx.time()
local bucket = math.floor(now / opts.window)
local reset = (bucket + 1) * opts.window - now
-- md5 evita guardar la API key en claro y acota la longitud de la clave
local key = "rl:" .. ngx.md5(id) .. ":" .. bucket
local red = redis:new()
red:set_timeouts(100, 100, 100)
local ok, err = red:connect(opts.host, opts.port)
if not ok then
ngx.log(ngx.ERR, "ratelimit: no se puede conectar a Redis: ", err)
return
end
if opts.password and red:get_reused_times() == 0 then
ok, err = red:auth(opts.password)
if not ok then
ngx.log(ngx.ERR, "ratelimit: fallo de autenticación en Redis: ", err)
return
end
end
local current
current, err = red:eval(SCRIPT, 1, key, opts.window)
if not current then
ngx.log(ngx.ERR, "ratelimit: error en Redis: ", err)
return
end
red:set_keepalive(10000, 100)
local remaining = opts.limit - current
if remaining < 0 then
remaining = 0
end
ngx.ctx.ratelimit = { limit = opts.limit, remaining = remaining, reset = reset }
if current > opts.limit then
ngx.status = 429
ngx.header["Content-Type"] = "application/json"
ngx.header["Retry-After"] = reset
ngx.say('{"detail":"Too many requests"}')
return ngx.exit(ngx.HTTP_OK)
end
end
function _M.headers()
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
end
return _M
Detalles importantes:
set_keepalivedevuelve la conexión a un pool por worker, así que no se abre una conexión TCP nueva a Redis en cada petición.get_reused_times() == 0evita repetirAUTHen las conexiones reutilizadas.ngx.exit(ngx.HTTP_OK)tras enviar el cuerpo termina la petición con el estado429fijado enngx.status. En la fase de acceso no usesngx.exit(ngx.OK), que dejaría continuar la petición.- Una ventana fija permite, en el peor caso, hasta el doble del límite en el cambio de una ventana a la siguiente. Para límites por minuto u hora de una API suele ser aceptable; si no lo es, combínalo con
limit_reqlocal para cortar las ráfagas.
Paso 8: Configurar OpenResty y probar el límite global
El nginx.conf de OpenResty está en /usr/local/openresty/nginx/conf/. Sustitúyelo por una configuración mínima que cargue el módulo y los sitios de un directorio propio:
sudo cp /usr/local/openresty/nginx/conf/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf.orig
sudo mkdir -p /usr/local/openresty/nginx/conf/sites
sudo nano /usr/local/openresty/nginx/conf/nginx.conf
worker_processes auto;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
lua_package_path "/usr/local/openresty/nginx/conf/lua/?.lua;;";
include sites/*.conf;
}
Crea el sitio de la API. El límite global es de 100 peticiones por minuto por API key o IP:
sudo nano /usr/local/openresty/nginx/conf/sites/api.conf
server {
listen 80;
listen [::]:80;
server_name your_domain;
location /api/ {
access_by_lua_block {
require("ratelimit").limit({
host = "your_redis_ip",
port = 6379,
password = "your_redis_password",
limit = 100,
window = 60,
})
}
header_filter_by_lua_block {
require("ratelimit").headers()
}
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Usa 127.0.0.1 como your_redis_ip si Redis está en el mismo servidor. El archivo contiene la contraseña de Redis, así que restringe sus permisos, valida la configuración y arranca OpenResty:
sudo chmod 600 /usr/local/openresty/nginx/conf/sites/api.conf
sudo openresty -t
sudo systemctl enable --now 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
Comprueba las cabeceras de una petición:
curl -si -H 'Host: your_domain' http://127.0.0.1/api/ | grep -i ratelimit
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 42
Repite la configuración en el resto de proxies apuntando al mismo Redis. Si envías peticiones alternando entre dos proxies, verás que X-RateLimit-Remaining baja de forma continua en ambos, porque comparten el contador. Puedes ver los contadores activos en Redis:
redis-cli -a 'your_redis_password' --no-auth-warning --scan --pattern 'rl:*'
rl:0b6f1a3c9e2d4f5a8b7c6d5e4f3a2b1c:29809316
Si Redis cae, las peticiones siguen pasando y el log de errores de OpenResty lo refleja:
sudo tail -n 2 /usr/local/openresty/nginx/logs/error.log
Solución de problemas
Todos los clientes reciben 429 a la vez. Todas las peticiones llegan con la misma IP, normalmente la de un balanceador o CDN. Configura realip como se indica en el paso 5.
Nginx devuelve 503 en lugar de 429. Falta limit_req_status 429; o está en un contexto que no aplica a esa location.
Los límites no se aplican. Comprueba que la location hace proxy_pass o sirve archivos: una directiva return o rewrite ... last en la misma location se ejecuta antes que limit_req y que access_by_lua_block.
openresty -t falla con module 'ratelimit' not found. La ruta de lua_package_path no coincide con la ubicación del archivo .lua. Debe terminar en ?.lua y apuntar al directorio que contiene ratelimit.lua.
ratelimit: no se puede conectar a Redis: timeout en el log. El proxy no llega a Redis: revisa bind en redis.conf, la regla de UFW y que la IP sea la privada.
Conclusión
Has limitado tu API con limit_req de Nginx por IP, con un cupo mayor para los clientes con API key válida y un límite estricto en el login, has aprendido a calibrar los valores con el modo de prueba y has compartido un límite global entre varios proxies con OpenResty y Redis. Como siguientes pasos, puedes asignar límites distintos por plan añadiendo zonas por tipo de cliente en el map, enviar el log api-ratelimit.log a tu sistema de monitorización para alertar de picos de rechazos o proteger con Fail2ban las IPs que acumulan 429 en el login.
