Configurar nginx como reverse proxy

Publicado el 16 min de lectura

De proxy_pass al upgrade de WebSocket: la guía completa para montar nginx como reverse proxy, incluidas las cuatro cabeceras sin las que tu aplicación toma a cada visitante por 127.0.0.1.

Casi toda aplicación moderna escucha en algún puerto alto: Node en el 3000, un container Docker en el 8080, Gunicorn en el 8000, un servidor de aplicaciones Java en el 8443. Eso no se expone directamente a internet. Delante va un reverse proxy y, en la práctica, ese reverse proxy es nginx.

La configuración básica cabe en cinco líneas. Ahí está justo el problema: esas cinco líneas parecen funcionar y, tres semanas después, descubres que cada visitante aparece en el log de la aplicación como 127.0.0.1, que el rate limit del inicio de sesión bloquea a todo el mundo a la vez y que el correo de restablecimiento de contraseña contiene un enlace a http://127.0.0.1:3000. Este artículo trata exactamente esos puntos.

El requisito previo es tener nginx instalado. Si todavía no lo está, te sirve instalar nginx en Debian y Ubuntu.

Qué hace realmente un reverse proxy a nivel técnico

nginx acepta la conexión del visitante y a continuación abre una segunda conexión propia hacia la aplicación. Ese es el punto decisivo del que se derivan todos los demás problemas.

Desde el punto de vista de la aplicación, el cliente no es el visitante, sino nginx. La IP de origen es 127.0.0.1. El protocolo es http, aunque por fuera estuviera funcionando HTTPS. La cabecera Host vale por defecto 127.0.0.1:3000 y no app.example.com. Y se habla HTTP/1.0 en lugar de HTTP/1.1, motivo por el cual los WebSockets fallan siempre sin configuración adicional.

Todo lo que la aplicación deba saber sobre el visitante real se lo tiene que pasar nginx de forma explícita como cabecera HTTP. Por sí solo no ocurre.

La configuración básica y dónde colocarla

La ubicación del archivo cambia según el sistema y eso se confunde con frecuencia.

Debian y Ubuntu: la configuración va en /etc/nginx/sites-available/app.conf y se activa mediante un symlink en /etc/nginx/sites-enabled/. Si no, el bloque server estándar default captura todas las peticiones.

AlmaLinux, Rocky, RHEL y Oracle Linux: ahí sites-available no existe en absoluto. El archivo va directamente a /etc/nginx/conf.d/app.conf y queda activo de inmediato.

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

Activación en Debian y Ubuntu:

ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx

Ejecutar nginx -t antes de cada recarga no es cortesía, es obligatorio. Un systemctl reload con una configuración defectuosa deja el proceso antiguo en marcha, pero un reinicio posterior del servidor ya no levanta nginx.

Todos los comandos de esta sección requieren permisos de root; si no los tienes, antepón sudo. Esto vale también para los comandos de simple comprobación: nginx -t y nginx -T como usuario normal no fallan por la configuración, sino por un archivo que no tienen permiso para escribir. El mensaje [emerg] open() "/run/nginx.pid" failed (13: Permission denied) seguido de configuration file /etc/nginx/nginx.conf test failed no significa, por tanto, que tu configuración esté rota.

Esta configuración reenvía el tráfico. Aun así está rota, y de una manera que solo se nota más tarde.

Las cuatro cabeceras sin las que la aplicación está ciega

Estas cuatro líneas tienen que estar en cada bloque location con proxy_pass:

location / {
    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;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Host

Sin esta línea, nginx envía Host: 127.0.0.1:3000. Los síntomas: Django responde con Invalid HTTP_HOST header y devuelve un 400. Laravel y WordPress generan URLs absolutas hacia 127.0.0.1, visibles en las redirecciones y en cada correo enviado. Un backend que atiende varios dominios entrega siempre el tenant equivocado.

La elección correcta es $host y no $http_host: $host contiene el nombre de host sin puerto y recurre al server_name cuando un cliente no envía ninguna cabecera Host. $http_host deja pasar lo que llegue, incluida la indicación del puerto.

X-Real-IP

$remote_addr es la IP con la que nginx habla realmente. Exactamente una dirección, sin comas y sin necesidad de parsear nada. Para aplicaciones que solo conocen un campo para la IP del cliente, es el camino más sencillo.

X-Forwarded-For

$proxy_add_x_forwarded_for toma una cabecera X-Forwarded-For que ya existiera y le añade $remote_addr por la derecha. Con varios proxies se forma así una cadena.

Y aquí hay un agujero de seguridad que casi ninguna guía menciona: un cliente puede enviar él mismo una cabecera X-Forwarded-For. Si tu nginx está directamente expuesto a internet, $proxy_add_x_forwarded_for junta en una misma lista el valor inventado por el atacante y tu IP de cliente real. Si tu aplicación lee entonces la primera entrada como IP del cliente, se cree cualquier dirección. Con eso se pueden burlar los rate limits, los bloqueos por IP y la lógica geográfica.

Dos consecuencias claras:

  • La cadena se lee siempre desde la derecha. La última entrada es la única que ha escrito tu propio proxy.
  • Si nginx es la única instancia delante de la aplicación, mejor sobrescribe la cadena por completo en lugar de ampliarla:
proxy_set_header X-Forwarded-For $remote_addr;

Así desaparece cualquier historial falsificado. Solo cuando por delante hay un load balancer o un CDN en el que confías de verdad tiene sentido $proxy_add_x_forwarded_for. En ese caso hay que configurar además el módulo realip, para que ya el propio nginx conozca la IP real y no registre la del salto anterior:

set_real_ip_from 10.0.0.0/8;
real_ip_header   X-Forwarded-For;
real_ip_recursive on;

La indicación set_real_ip_from es una whitelist. Sin ella el módulo no tiene ningún efecto y, con un rango demasiado amplio como 0.0.0.0/0, se convierte en una puerta abierta.

X-Forwarded-Proto

La aplicación ve una conexión HTTP pura, pase lo que pase por fuera. Si falta esta cabecera, lo habitual es lo siguiente: la aplicación detecta "no hay HTTPS" y redirige a HTTPS, nginx recibe la petición, la descifra, la vuelve a pasar como HTTP y la aplicación redirige otra vez. El navegador informa de ERR_TOO_MANY_REDIRECTS. Igual de frecuente: las cookies con el flag Secure no se establecen, los inicios de sesión fallan sin mensaje de error y las páginas cargan imágenes y scripts por http://, lo que el navegador bloquea como mixed content.

Usa $scheme y no el valor fijo "https". Si no, también el bloque del puerto 80 afirmará que la conexión venía cifrada.

La prueba de que las cabeceras llegan de verdad

En lugar de adivinar, móntate un espejo. Este bloque server adicional responde a cada petición con las cabeceras recibidas en texto plano:

server {
    listen 127.0.0.1:9999;
    default_type text/plain;

    location / {
        return 200 "Host: $host\nX-Real-IP: $http_x_real_ip\nX-Forwarded-For: $http_x_forwarded_for\nX-Forwarded-Proto: $http_x_forwarded_proto\nProtocolo: $server_protocol\n";
    }
}

Apunta proxy_pass de forma temporal a http://127.0.0.1:9999, recarga nginx y abre la página. Lo que aparezca ahí es exactamente lo que habría recibido tu aplicación. Si las cuatro líneas tienen valor y la IP coincide con la de tu conexión real, la configuración es correcta. Después vuelve a quitar el bloque de prueba.

Ahora bien, la aplicación también tiene que evaluar esas cabeceras. Express necesita app.set('trust proxy', 1), Symfony el ajuste trusted_proxies, y Django USE_X_FORWARDED_HOST junto con SECURE_PROXY_SSL_HEADER. Sin ese interruptor, los frameworks ignoran las cabeceras a propósito, justo por el motivo de spoofing descrito arriba.

proxy_pass y la barra que lo cambia todo

Es el fallo silencioso más frecuente de toda la configuración de nginx. Un único carácter decide qué ruta llega al backend.

ConfiguraciónPeticiónEl backend recibe
location /api/ { proxy_pass http://127.0.0.1:3000; }/api/users/api/users
location /api/ { proxy_pass http://127.0.0.1:3000/; }/api/users/users
location /api/ { proxy_pass http://127.0.0.1:3000/v2/; }/api/users/v2/users

La regla: en cuanto detrás del host y del puerto aparece cualquier ruta, aunque sea solo una barra, nginx sustituye la parte de la URL que coincide con el prefijo del location. Sin indicación de ruta, la URL completa se pasa sin cambios.

El cuadro de fallo es característico: la página de inicio funciona, pero todo lo que cuelga de un prefijo devuelve 404 y en el log del backend aparecen rutas con el prefijo duplicado, como /api/api/users. Un vistazo al log de la aplicación lo aclara en segundos, mientras que adivinar en nginx cuesta horas.

Dos casos especiales que generan sus propios mensajes de error. En un location con expresión regular está prohibido indicar una ruta; de lo contrario nginx aborta al arrancar con nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, inside named location, or inside "if" statement. Y en cuanto usas una variable en proxy_pass, por ejemplo proxy_pass http://$backend;, nginx deja de resolver el nombre al arrancar y lo resuelve en tiempo de ejecución. Sin una línea resolver en el bloque server, eso acaba en no resolver defined to resolve ... y en un 502.

Reenviar WebSockets

Por defecto, nginx habla HTTP/1.0 con el backend. HTTP/1.0 no conoce el mecanismo de upgrade. Por eso todos los WebSockets fracasan con la configuración básica, por muy correcto que sea todo lo demás.

Síntomas típicos: la consola del navegador informa de WebSocket connection to 'wss://app.example.com/ws' failed, a menudo con el añadido Error during WebSocket handshake: Unexpected response code: 400. Socket.io recurre en silencio al long polling, la aplicación solo parece lenta y en el access log se repiten sin fin líneas con /socket.io/?EIO=4&transport=polling.

Primero, el mapeo que decide si una conexión pide realmente un upgrade. Va en el contexto http, así que lo mejor es ponerlo en un archivo propio, /etc/nginx/conf.d/websocket.conf:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Si este bloque acaba por error dentro de un bloque server o location, nginx deja de arrancar: nginx: [emerg] "map" directive is not allowed here.

Después, en el bloque location:

location / {
    proxy_pass http://127.0.0.1:3000;

    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

¿Por qué el mapeo y no simplemente proxy_set_header Connection "upgrade";? Porque entonces también las peticiones HTTP normales piden un upgrade. Algunos backends responden a eso con un 400 y se pierde la reutilización de keepalive. El mapeo envía upgrade solo cuando el cliente lo ha solicitado de verdad y, en caso contrario, envía close.

La prueba: en las herramientas de desarrollo del navegador, la petición WebSocket tiene que mostrar el estado 101 Switching Protocols. Cualquier otra cosa, en especial un 200 o un 400, significa que el upgrade no ha pasado. En la línea de comandos también funciona sin navegador:

curl -sSi -o /dev/null -w '%{http_code}\n' \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  http://127.0.0.1/ws

Configurar bien los timeouts

Si una conexión se corta de forma reproducible tras exactamente 60 segundos, no es casualidad, es el valor por defecto de proxy_read_timeout. Importante para entenderlo: el valor no limita la duración total de la petición, sino la pausa entre dos operaciones de lectura. Una descarga que dura diez minutos pero entrega datos de forma continua pasa sin problemas. Un WebSocket en el que no ocurre nada durante 61 segundos se cae.

proxy_connect_timeout 10s;
proxy_send_timeout    60s;
proxy_read_timeout    60s;

proxy_connect_timeout solo se aplica al establecimiento de la conexión y tiene un tope de 75 segundos. Subirlo más no sirve de nada. Con un backend local, 10 segundos son generosos, y un valor bajo te permite darte cuenta antes de que el servicio ni siquiera está en marcha.

Para los WebSockets, el mejor camino no es proxy_read_timeout 86400s;, sino un heartbeat en la aplicación que envíe un ping cada 30 segundos. Así el timeout se conserva como protección contra conexiones colgadas en lugar de quedar desactivado en la práctica.

Otros dos valores por defecto que golpean con regularidad. client_max_body_size está en 1 MB, así que cualquier subida mayor termina en 413 Request Entity Too Large y en la línea de log client intended to send too large body. Y con Server-Sent Events o respuestas en streaming, al visitante no le llega nada durante mucho rato porque nginx hace buffering. Ahí ayuda proxy_buffering off; en el bloque location afectado, de forma puntual y no global.

Poner HTTPS por delante

El camino más cómodo es certbot con el plugin de nginx. Lee el bloque server existente, añade la parte TLS y configura la renovación:

apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d app.example.com

Estos nombres de paquete valen para Debian y Ubuntu. En AlmaLinux, Rocky Linux y Oracle Linux, certbot no está en los repositorios base, así que un simple dnf install -y certbot python3-certbot-nginx termina ahí con Error: Unable to find a match. EPEL es imprescindible:

dnf install -y epel-release
dnf install -y certbot python3-certbot-nginx

En Oracle Linux 9, el paquete de EPEL se llama oracle-epel-release-el9 y, llegado el caso, hay que habilitar antes el repositorio con dnf config-manager --enable ol9_developer_EPEL.

Si tienes varios subdominios, merece la pena echar un vistazo a los certificados wildcard de Let's Encrypt.

Una diferencia de versión que genera avisos al copiar configuraciones ajenas: hasta nginx 1.24, HTTP/2 se activa en la línea listen; a partir de nginx 1.25.1 existe una directiva propia para ello. Debian 13 trae nginx 1.26.3 y quiere la notación nueva, mientras que Debian 12 (1.22.1), Ubuntu 24.04 (1.24.0) y Ubuntu 22.04 (1.18.0) quieren la antigua. En el lado de Red Hat la frontera es la misma: AlmaLinux 10 incluye 1.26.3 y con ello la forma nueva, mientras que AlmaLinux 9, Rocky Linux 9 y Oracle Linux 9 incluyen 1.20.1 y necesitan la antigua.

# nginx a partir de 1.25.1, entre otros Debian 13
listen 443 ssl;
http2 on;

# nginx hasta 1.24, es decir Debian 12, Ubuntu 24.04 y 22.04
listen 443 ssl http2;

Si usas la forma antigua en un nginx nuevo, nginx -t informa: nginx: [warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead. Es solo un aviso, así que sigue funcionando. La forma nueva en un nginx antiguo, en cambio, es un error de arranque duro: unknown directive "http2".

Para terminar, el punto más importante de todo el ejercicio: el backend no puede colgar por sí mismo de internet. Un reverse proxy no sirve de nada si http://server-ip:3000 sigue siendo accesible de forma directa, porque entonces cualquiera puede poner las cabeceras a su gusto. Haz que el servicio escuche en 127.0.0.1.

Con Docker la trampa es especialmente afilada: -p 3000:3000 publica el puerto en todas las direcciones y para ello añade reglas que sencillamente esquivan un firewall ufw. Lo correcto es -p 127.0.0.1:3000:3000. Los detalles de la instalación están en instalar Docker en Debian y Ubuntu.

Si de forma excepcional haces de proxy hacia un backend HTTPS, nginx necesita una línea adicional; de lo contrario no envía ningún nombre SNI y el otro extremo entrega el certificado equivocado:

proxy_pass https://backend.intern:8443;
proxy_ssl_server_name on;

Cuando algo sale mal: los mensajes literales

El primer sitio al que acudir es siempre tail -f /var/log/nginx/error.log. La página de error del navegador no dice nada, el log lo dice todo.

Un detalle previo, para que la primera línea no despiste: en AlmaLinux, Rocky Linux y Oracle Linux, el archivo /var/log/nginx/error.log no existe justo después de la instalación, se crea con el primer arranque de nginx. tail responde entonces con cannot open ... No such file or directory. En Debian y Ubuntu, el paquete crea access.log y error.log ya durante la instalación. Una variante robusta para ambos mundos:

tail -n 50 /var/log/nginx/error.log 2>/dev/null || journalctl -u nginx -n 50 --no-pager
Mensaje en el error.logSignificado y solución
connect() failed (111: Connection refused) while connecting to upstreamNo hay nada escuchando en el puerto de destino. Comprueba el estado del servicio y verifica con ss -ltnp si el puerto y la dirección coinciden con la línea proxy_pass. Si falta ss, en Debian y Ubuntu lo aporta el paquete iproute2 y en la familia Red Hat el paquete iproute (ahí sin el 2 en el nombre).
connect() failed (113: No route to host)Un firewall entre nginx y el backend está bloqueando. Con containers suele tratarse de una red equivocada.
connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstreamEn AlmaLinux, Rocky, RHEL y Oracle Linux, casi siempre es SELinux. Lo confirmas con ausearch -m AVC -ts recent y lo resuelves con setsebool -P httpd_can_network_connect 1. En Debian y Ubuntu no aparece.
upstream timed out (110: Connection timed out) while reading response header from upstreamDa un 504. El backend responde demasiado lento. Mira primero ahí y solo después sube proxy_read_timeout.
upstream prematurely closed connection while reading response headerDa un 502. El proceso del backend ha muerto durante la petición, a menudo por culpa del OOM killer. Consulta configurar swap.
upstream sent too big header while reading response header from upstreamCabeceras de respuesta demasiado grandes, algo clásico con cookies muy numerosas o muy largas. Define proxy_buffer_size 32k; y proxy_buffers 8 32k;.
no live upstreams while connecting to upstreamEn un bloque upstream, todos los destinos se han marcado como caídos. Ajusta el comportamiento de salud con max_fails y fail_timeout.

Para los casos especiales alrededor del 502 hay una guía propia: solucionar el error nginx 502 Bad Gateway. Si tu backend todavía no es un paquete de servicio que arranque limpiamente, antes merece la pena crear un servicio systemd.

Cómo saber que funciona de verdad

Cinco comprobaciones que, juntas, resultan concluyentes:

  1. nginx -t informa de syntax is ok y test is successful.
  2. nginx -T muestra la configuración completa ya ensamblada. Busca ahí proxy_set_header y cuenta: las cuatro cabeceras tienen que aparecer en cada bloque location relevante. Un proxy_set_header dentro de un bloque interior anula todas las cabeceras heredadas del bloque exterior, y esa es la causa más frecuente del clásico "pero si yo eso ya lo había puesto".
  3. En el log de la aplicación aparece la IP real del visitante y no 127.0.0.1.
  4. El bloque server espejo de más arriba devuelve los cuatro valores rellenos, con X-Forwarded-Proto: https al acceder por HTTPS.
  5. Para los WebSockets: estado 101 en las herramientas de desarrollo, y la conexión sobrevive a más de 60 segundos de silencio.

También ayuda un formato de log que registre la IP reenviada. Así ves de inmediato si nginx y la aplicación se refieren a la misma dirección:

log_format proxied '$remote_addr xff="$http_x_forwarded_for" '
                   'host=$host "$request" $status $body_bytes_sent '
                   'upstream=$upstream_addr rt=$request_time urt=$upstream_response_time';

access_log /var/log/nginx/app.access.log proxied;

Los dos valores de tiempo del final valen oro: $request_time es la duración total desde el punto de vista del visitante y $upstream_response_time es la duración de la respuesta del backend. Si ambos están muy próximos, el backend es lento. Si se abre una brecha entre ellos, la culpa es de la línea hacia el cliente o del buffering.

Con esto queda montado un reverse proxy que no solo hace accesible la aplicación, sino que además le entrega todo lo que necesita saber sobre sus visitantes. Si montas esta configuración en un sistema recién instalado, la checklist para servidores root nuevos es un buen punto de partida para todo lo que va antes.

Preguntas frecuentes

¿Por qué mi aplicación ve 127.0.0.1 en lugar de la IP real del visitante?
Porque nginx abre una segunda conexión propia hacia el backend. Desde el punto de vista de la aplicación, el cliente es entonces nginx. La dirección real se tiene que pasar de forma explícita como cabecera, con proxy_set_header X-Real-IP $remote_addr y proxy_set_header X-Forwarded-For. Además, el framework tiene que permitir su evaluación, por ejemplo con app.set('trust proxy', 1) en Express o con SECURE_PROXY_SSL_HEADER en Django.
¿Cuál es la diferencia entre X-Real-IP y X-Forwarded-For?
X-Real-IP contiene exactamente una dirección, la del cliente conectado de forma directa. X-Forwarded-For es una cadena separada por comas a la que cada proxy añade a su predecesor. Esa cadena se tiene que leer siempre desde la derecha, porque solo la última entrada procede con seguridad de tu propio proxy. Todo lo que hay más a la izquierda lo puede haber falsificado un cliente.
¿Por qué no funcionan mis WebSockets detrás de nginx?
nginx habla con el backend en HTTP/1.0 por defecto, y HTTP/1.0 no conoce ningún mecanismo de upgrade. Hacen falta proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade y proxy_set_header Connection $connection_upgrade, teniendo en cuenta que la variable $connection_upgrade se tiene que definir con un bloque map en el contexto http. Sale bien cuando el navegador muestra el estado 101 Switching Protocols.
¿Por qué mi conexión se corta siempre a los 60 segundos exactos?
60 segundos es el valor por defecto de proxy_read_timeout. Ese valor no limita la duración total, sino la pausa entre dos operaciones de lectura. Para conexiones de larga duración se puede subir, pero en WebSockets es mejor un heartbeat en la aplicación, así el timeout se conserva como protección.
¿Qué efecto tiene la barra al final de proxy_pass?
Si detrás del host y del puerto hay una ruta, aunque sea una sola barra, nginx sustituye la parte de la URL que coincide con el prefijo del location. Con location /api/ y proxy_pass http://127.0.0.1:3000/, la petición /api/users llega al backend como /users. Sin la barra, /api/users llega sin cambios. El cuadro de fallo típico cuando se elige mal son los 404 por debajo del prefijo o las rutas duplicadas del estilo /api/api/users.
¿Por qué obtengo un 502 con Permission denied en AlmaLinux?
En la familia Red Hat, SELinux está activo por defecto y prohíbe las conexiones de red salientes al contexto del servidor web. En el error.log aparece entonces connect() failed (13: Permission denied) while connecting to upstream. La solución es setsebool -P httpd_can_network_connect 1, y el cambio surte efecto de inmediato sin recargar nginx. En Debian y Ubuntu el problema no aparece.

nginx Reverse Proxy proxy_pass WebSocket X-Forwarded-For HTTPS Linux Administración de servidores