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

  1. 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, EXPOSE y la variable PORT deben coincidir con la dirección usada por el proxy.

  2. Variables de entorno mal propagadas – Variables como MAIN_URL, NEXT_PUBLIC_BACKEND_URL o NOT_SECURED influyen en la generación de URLs internas. Si el frontend construye la URL del API con https://.../api pero el proxy solo escucha HTTP en 127.0.0.1:3000, la petición se dirige a una dirección que Nginx no puede resolver.

  3. Configuración de Nginx que apunta a 127.0.0.1 – Dentro de un contenedor, 127.0.0.1 se 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.

  4. 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_on o de healthchecks en Docker‑Compose exacerba este comportamiento.

  5. 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 PORT del proceso backend tenga ese valor.
  • En el Dockerfile o en la definición del contenedor, usa EXPOSE 3000.
  • En la configuración de Nginx, apunta el upstream al nombre del servicio Docker (si usas Docker‑Compose) o a localhost solo 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_URL debe apuntar a la ruta que Nginx expone, normalmente https://mi-dominio.com/api.
  • MAIN_URL y FRONTEND_URL pueden quedar como https://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, mantener NOT_SECURED=false y 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

  1. Ejecuta docker ps y confirma que los tres contenedores están en estado Up.
  2. Usa curl -I http://localhost/api/auth/login desde el host. Debería devolver 200 (o 401 si la credencial es incorrecta) y no 502.
  3. 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.
  4. Revisa docker logs nginx y busca la ausencia de líneas connect() 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/certs y añade listen 443 ssl; en la sección server.
  • Mantener NOT_SECURED=false evita problemas de cookies “Secure” en navegadores modernos. Solo habilita true para 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.