Problema
En entornos donde una aplicación monolítica se ejecuta dentro de un contenedor y expone dos procesos –por lo general un frontend (Next.js, React, etc.) y un backend (NestJS, Express, etc.)– es frecuente colocar un servidor Nginx como reverse proxy interno. Cuando el proxy no logra establecer la conexión con el proceso backend, los navegadores devuelven un 502 Bad Gateway y, en la práctica, la UI queda atrapada en un bucle de carga (por ejemplo, al intentar iniciar sesión). El síntoma típico es una petición POST /api/... que nunca finaliza y, en los logs de Nginx, aparece:
connect() failed (111: Connection refused) while connecting to upstream,
upstream: "http://127.0.0.1:3000/..."
Este patrón se repite en despliegues rápidos con Docker, EasyPanel, Docker‑Compose o cualquier herramienta que abstraiga la gestión de puertos. El problema no es exclusivo de una aplicación concreta; es la desconexión entre la configuración del proxy y la forma en que el backend escucha sus puertos.
Causa
-
Desalineación de puertos – El backend está configurado para escuchar en un puerto (ej. 3000) mientras que Nginx intenta redirigir al puerto que realmente no está expuesto dentro del contenedor (ej. 5000). En contenedores Docker,
EXPOSEy la variablePORTdeben coincidir con la dirección usada por el proxy. -
Variables de entorno mal propagadas – Variables como
MAIN_URL,NEXT_PUBLIC_BACKEND_URLoNOT_SECUREDinfluyen en la generación de URLs internas. Si el frontend construye la URL del API conhttps://.../apipero el proxy solo escucha HTTP en127.0.0.1:3000, la petición se dirige a una dirección que Nginx no puede resolver. -
Configuración de Nginx que apunta a
127.0.0.1– Dentro de un contenedor,127.0.0.1se refiere al propio proceso Nginx, no al proceso backend que corre en otro contenedor o en otro PID namespace. Cuando ambos procesos comparten el mismo contenedor, el problema suele ser que el backend no está activo en el momento en que Nginx arranca. -
Orden de arranque – Si Nginx se inicia antes que el backend, la primera petición fallará y, dependiendo de la lógica de reintento del cliente, puede quedar atrapada en un bucle. La falta de
depends_ono de healthchecks en Docker‑Compose exacerba este comportamiento. -
Modo “secure” vs “not secured” – Forzar HTTPS en el frontend mientras el proxy solo escucha HTTP genera redirecciones que terminan en un “connection refused” cuando el cliente intenta contactar el puerto HTTPS interno que no está abierto.
Solución
1. Unificar puertos y exponerlos explícitamente
- Decide un puerto único para el backend (ej. 3000) y asegúrate de que la variable
PORTdel proceso backend tenga ese valor. - En el
Dockerfileo en la definición del contenedor, usaEXPOSE 3000. - En la configuración de Nginx, apunta el
upstreamal nombre del servicio Docker (si usas Docker‑Compose) o alocalhostsolo cuando ambos procesos comparten contenedor.
# docker-compose.yml (fragmento)
services:
backend:
image: myapp-backend
environment:
- PORT=3000
expose:
- "3000"
nginx:
image: nginx:alpine
depends_on:
backend:
condition: service_healthy
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
2. Ajustar el bloque upstream de Nginx
Si backend y Nginx están en el mismo contenedor, usa 127.0.0.1:3000. Si están en contenedores diferentes, usa el nombre del servicio Docker (backend:3000). Evita localhost cuando el proceso está en otro contenedor.
# nginx.conf (fragmento)
upstream backend {
server backend:3000; # nombre del servicio Docker
keepalive 16;
}
server {
listen 80;
server_name _;
location /api/ {
proxy_pass http://backend;
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 / {
proxy_pass http://frontend:4200;
}
}
3. Sincronizar variables de entorno
NEXT_PUBLIC_BACKEND_URLdebe apuntar a la ruta que Nginx expone, normalmentehttps://mi-dominio.com/api.MAIN_URLyFRONTEND_URLpueden quedar comohttps://mi-dominio.com.- Si el contenedor sirve tráfico HTTPS directamente, habilita certificados en Nginx y elimina
NOT_SECURED=true. En la mayoría de los despliegues simples, mantenerNOT_SECURED=falsey dejar que Nginx haga terminación TLS es la opción más segura.
4. Añadir healthchecks y dependencias
En Docker‑Compose, define un healthcheck para el backend que verifique que el puerto está abierto. Luego, usa depends_on con condition: service_healthy para que Nginx arranque sólo cuando el backend responda.
# healthcheck para backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
5. Reiniciar y observar logs
Después de aplicar los cambios, reconstruye los contenedores (docker-compose up -d --build) y revisa los logs de Nginx y del backend. Busca que la línea connect() failed desaparezca y que las peticiones a /api/auth/login devuelvan 200 o el código de error esperado por la lógica de la aplicación.
Cuándo aplicar esta solución
- Síntomas: 502 Bad Gateway en peticiones API, bucle de carga en login/registro, logs de Nginx con “connection refused”.
- Entorno: Docker, Docker‑Compose, EasyPanel, o cualquier plataforma que use un contenedor con Nginx como reverse proxy interno.
- No aplica: Cuando el 502 proviene de un upstream externo (por ejemplo, un servicio SaaS) o cuando la aplicación ya está distribuida en microservicios con balanceadores de carga externos.
Código
# docker-compose.yml completo (ejemplo mínimo)
version: "3.8"
services:
backend:
image: postiz-backend:latest
environment:
- PORT=3000
- DATABASE_URL=postgres://postgres:pwd@db:5432/postiz
- REDIS_URL=redis://redis:6379
- JWT_SECRET=supersecret
expose:
- "3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
frontend:
image: postiz-frontend:latest
environment:
- NEXT_PUBLIC_BACKEND_URL=https://mi-dominio.com/api
expose:
- "4200"
nginx:
image: nginx:alpine
depends_on:
backend:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
Verificación
- Ejecuta
docker psy confirma que los tres contenedores están en estado Up. - Usa
curl -I http://localhost/api/auth/logindesde el host. Debería devolver200(o401si la credencial es incorrecta) y no502. - Abre la aplicación en el navegador, intenta iniciar sesión y verifica que el spinner desaparece y la respuesta del API llega sin errores.
- Revisa
docker logs nginxy busca la ausencia de líneasconnect() failed.
Notas adicionales
- En entornos con EasyPanel, el puerto interno asignado a la “Domain” debe coincidir con el puerto que Nginx expone (usualmente 80/443). Cambiarlo a 5000 sin actualizar la configuración de Nginx genera el mismo 502.
- Si decides terminar TLS dentro del contenedor, monta los certificados en
/etc/nginx/certsy añadelisten 443 ssl;en la secciónserver. - Mantener
NOT_SECURED=falseevita problemas de cookies “Secure” en navegadores modernos. Solo habilitatruepara pruebas locales sin HTTPS. - Cuando uses un dominio dinámico como DuckDNS, asegúrate de que el certificado (por ejemplo, de Let’s Encrypt) incluya el subdominio; de lo contrario, el navegador bloqueará la petición antes de que llegue a Nginx.