Configurar nginx como reverse proxy
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ón | Petición | El 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.log | Significado y solución |
|---|---|
connect() failed (111: Connection refused) while connecting to upstream | No 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 upstream | En 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 upstream | Da 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 header | Da 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 upstream | Cabeceras 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 upstream | En 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:
nginx -tinforma desyntax is okytest is successful.nginx -Tmuestra la configuración completa ya ensamblada. Busca ahíproxy_set_headery cuenta: las cuatro cabeceras tienen que aparecer en cada bloque location relevante. Unproxy_set_headerdentro 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".- En el log de la aplicación aparece la IP real del visitante y no
127.0.0.1. - El bloque server espejo de más arriba devuelve los cuatro valores rellenos, con
X-Forwarded-Proto: httpsal acceder por HTTPS. - 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?
¿Cuál es la diferencia entre X-Real-IP y X-Forwarded-For?
¿Por qué no funcionan mis WebSockets detrás de nginx?
¿Por qué mi conexión se corta siempre a los 60 segundos exactos?
¿Qué efecto tiene la barra al final de proxy_pass?
¿Por qué obtengo un 502 con Permission denied en AlmaLinux?
2026 KernelHost GmbH. Todos los derechos reservados. Esta guía está protegida por derechos de autor. Su publicación en otros sitios web, aunque sea de forma parcial o modificada, no está permitida sin nuestro consentimiento por escrito. Las citas con indicación de la fuente y un enlace son muy bienvenidas.

