Problema

En entornos de producción con varios servicios Docker, es frecuente que los contenedores publiquen sus puertos directamente en la interfaz 0.0.0.0 del host. Esa práctica amplía la superficie de ataque porque cualquier cliente externo puede intentar conectar a esos puertos, aunque el objetivo sea que todo el tráfico pase por un reverse proxy. El desafío consiste en:

  1. Mantener los puertos internos del contenedor cerrados al host.
  2. Permitir que Nginx Proxy Manager (NPM) enrute peticiones HTTP/HTTPS al contenedor objetivo.
  3. Asegurar que la base de datos u otros servicios internos no queden expuestos accidentalmente.

El problema se manifiesta típicamente con errores 504 o “Bad Gateway” cuando NPM no logra alcanzar el backend, aunque el contenedor esté activo y saludable. La causa suele estar en la configuración de redes Docker o en la forma en que se exponen los puertos.

Causa

1. Publicación de puertos en el host

Cuando en docker‑compose.yml se escribe ports: "6060:6060" Docker crea una regla NAT que enlaza el puerto del host (6060) al puerto interno del contenedor. Si el host está accesible desde Internet, cualquiera puede intentar conectarse directamente, eludiendo el proxy.

2. Uso de una única red bridge

Docker crea por defecto una red bridge que conecta todos los contenedores del mismo proyecto. Si el reverse proxy y el backend comparten esa red, el proxy puede resolver el nombre del contenedor, pero el contenedor sigue estando accesible desde el host porque el mapeo de puertos está activo.

3. Falta de expose vs ports

expose solo abre el puerto dentro de la red Docker; no crea una regla en el host. Si se combina expose con ports, el puerto queda doblemente disponible y el proxy a veces intenta usar la dirección localhost:6060 en lugar de la IP interna del contenedor, generando timeouts.

4. Redes internas mal configuradas

Crear una red marcada como internal: true impide que los paquetes salgan de esa red, pero si el reverse proxy no está conectado a esa red, no podrá alcanzar el backend. El error 504 es el síntoma típico: NPM recibe la petición, la envía a la IP del contenedor, pero la ruta está bloqueada por la política de red.

Solución

Paso 1: Definir dos redes explícitas

  • proxy (bridge, accesible desde el host, expone 80/443).
  • backend (bridge, internal: true, solo para contenedores de la aplicación).
docker network create proxy
docker network create --internal backend

Paso 2: Conectar NPM a ambas redes

NPM necesita estar en proxy para escuchar en los puertos públicos y en backend para resolver los nombres internos. En docker‑compose.yml:

services:
  npm:
    image: jc21/nginx-proxy-manager:latest
    restart: unless-stopped
    networks:
      - proxy
      - backend
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./npm/data:/data
      - ./npm/letsencrypt:/etc/letsencrypt

Paso 3: Configurar el contenedor de la aplicación

  • No usar ports.
  • Usar expose para que el puerto sea visible dentro de backend.
  • Conectar a ambas redes si necesita comunicarse con la base de datos que está solo en backend.
  app:
    image: myorg/myapp:latest
    restart: unless-stopped
    environment:
      - DATABASE_URL=postgres://db_user:db_pass@db:5432/appdb
    expose:
      - "6060"
    networks:
      - backend
      - proxy   # opcional, solo si el proxy necesita resolver por nombre

Paso 4: Añadir la base de datos solo a backend

  db:
    image: postgres:15
    restart: unless-stopped
    environment:
      - POSTGRES_USER=db_user
      - POSTGRES_PASSWORD=db_pass
      - POSTGRES_DB=appdb
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - backend

Paso 5: Configurar NPM

En la UI de NPM crear un Proxy Host:

  • Domain Names: app.ejemplo.com
  • Scheme: http (o https si el contenedor ya sirve TLS)
  • Forward Hostname / IP: app (nombre del servicio en compose)
  • Forward Port: 6060

NPM resolverá app mediante la red backend porque está conectado a ella. No hay necesidad de especificar la IP 172.x.x.x.

Paso 6: Eliminar mapeos de puertos innecesarios

Revisa que ningún contenedor tenga la sección ports: a menos que realmente necesites acceso externo directo. Con expose el tráfico interno sigue funcionando y el proxy mantiene el control.

Cuándo aplicar esta solución

  • Entornos homelab o producción ligera donde varios microservicios comparten un reverse proxy.
  • Cuando se desea minimizar la superficie de ataque evitando puertos expuestos en el host.
  • Si ya se usa Nginx Proxy Manager o cualquier otro reverse proxy basado en nombre de host.

No es necesario si:

  • Solo ejecutas un contenedor y no te preocupa la exposición.
  • Usas orquestadores como Kubernetes que gestionan Ingress de forma nativa.

Código

yaml
version: "3.9"

networks:
  proxy:
    external: true
  backend:
    driver: bridge
    internal: true

services:
  npm:
    image: jc21/nginx-proxy-manager:latest
    restart: unless-stopped
    networks:
      - proxy
      - backend
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./npm/data:/data
      - ./npm/letsencrypt:/etc/letsencrypt

  app:
    image: myorg/myapp:latest
    restart: unless-stopped
    environment:
      - DATABASE_URL=postgres://db_user:db_pass@db:5432/appdb
    expose:
      - "6060"
    networks:
      - backend
      - proxy   # opcional, solo para resolución de nombre

  db:
    image: postgres:15
    restart: unless-stopped
    environment:
      - POSTGRES_USER=db_user
      - POSTGRES_PASSWORD=db_pass
      - POSTGRES_DB=appdb
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - backend

volumes:
  db_data:

Verificación

  1. Comprobar redes

    docker network inspect backend
    docker network inspect proxy
    

    Verifica que npm, app y db aparecen en las redes esperadas.

  2. Probar resolución interna

    docker exec -it npm ping -c 2 app
    

    Debería responder con la IP 172.x.x.x del contenedor app.

  3. Validar el proxy
    Desde una máquina externa, ejecutar:

    curl -I https://app.ejemplo.com
    

    Debería devolver 200 OK y los encabezados de NPM.

  4. Asegurar que el puerto host no está abierto

    ss -tlnp | grep 6060
    

    No debe aparecer ninguna línea; el puerto solo está escuchando dentro de la red Docker.

Notas adicionales

  • Orden de redes: Docker asigna la primera red como la predeterminada para la tabla de rutas. Si el contenedor necesita comunicarse con la base de datos, mantén backend como primera red en la lista.
  • Healthchecks: Añadir un healthcheck que apunte a http://localhost:6060/health ayuda a que Docker reinicie automáticamente el contenedor si la aplicación deja de responder.
  • TLS interno: Si la aplicación ya sirve HTTPS, configura NPM con Scheme = https y desactiva la verificación de certificado si usas un certificado autofirmado.
  • Logs: Los logs de NPM (/data/logs) son útiles para depurar 504; busca la línea “upstream timed out” y verifica la IP/puerto que está intentando contactar.
  • Escalado: Cuando añades réplicas del servicio app, usa un Docker Swarm o Compose con deploy: replicas: y mantén la red backend para que el balanceo interno siga funcionando sin exponer puertos adicionales.