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ámetroDóndeQué hace
ratelimit_req_zoneVelocidad sostenida permitida, en peticiones por segundo (r/s) o por minuto (r/m)
burstlimit_reqCuántas peticiones por encima de rate se aceptan de golpe antes de rechazar
nodelaylimit_reqAtiende 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_anon queda vacío y cuenta en api_clients (50 r/s por cliente); si no, $api_client queda vacío y cuenta en api_anon (5 r/s por IP).
  • La API key se compara con una lista de keys conocidas. Si usaras directamente $http_x_api_key como clave, cualquiera podría saltarse el límite enviando una key inventada distinta en cada petición.
  • $binary_remote_addr ocupa 4 bytes (16 en IPv6), menos que la IP en texto, y ahorra memoria en la zona.
  • limit_req_status 429 sustituye el 503 que Nginx devuelve por defecto, que los clientes interpretarían como una caída del servicio.
  • La variable $limit_req_status del formato de log indica el resultado de cada petición: PASSED, DELAYED, REJECTED o 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"

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_keepalive devuelve 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() == 0 evita repetir AUTH en las conexiones reutilizadas.
  • ngx.exit(ngx.HTTP_OK) tras enviar el cuerpo termina la petición con el estado 429 fijado en ngx.status. En la fase de acceso no uses ngx.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_req local 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.