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:
- Mantener los puertos internos del contenedor cerrados al host.
- Permitir que Nginx Proxy Manager (NPM) enrute peticiones HTTP/HTTPS al contenedor objetivo.
- 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
exposepara que el puerto sea visible dentro debackend. - 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(ohttpssi 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
-
Comprobar redes
docker network inspect backend docker network inspect proxyVerifica que
npm,appydbaparecen en las redes esperadas. -
Probar resolución interna
docker exec -it npm ping -c 2 appDebería responder con la IP
172.x.x.xdel contenedorapp. -
Validar el proxy
Desde una máquina externa, ejecutar:curl -I https://app.ejemplo.comDebería devolver
200 OKy los encabezados de NPM. -
Asegurar que el puerto host no está abierto
ss -tlnp | grep 6060No 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
backendcomo primera red en la lista. - Healthchecks: Añadir un
healthcheckque apunte ahttp://localhost:6060/healthayuda 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 condeploy: replicas:y mantén la redbackendpara que el balanceo interno siga funcionando sin exponer puertos adicionales.