Problema

En entornos de homelab o pequeñas infraestructuras es frecuente lanzar varios servicios en contenedores Docker y conectar todos ellos a la red predeterminada bridge. Esa configuración permite que cualquier contenedor alcance a otro usando su dirección IP y puerto expuesto, lo que rompe la premisa de “aislamiento por diseño”. Cuando varios servicios comparten la misma subred, un atacante que comprometa un contenedor puede explorar libremente los demás, y usuarios de la LAN pueden saltarse reglas de VLAN porque el tráfico nunca sale del host. El objetivo típico es que solo el reverse proxy sea la puerta de entrada y que el resto de contenedores queden invisibles para la red local y entre sí.

Causa

El comportamiento descrito proviene de tres factores habituales:

  1. Red predeterminada bridge sin segmentación – Docker asigna automáticamente una subred privada y conecta todos los contenedores a ella. No hay separación lógica entre aplicaciones distintas.
  2. Puertos publicados en el host – Cada vez que se usa ports: "HOST:CONTAINER" Docker abre una regla de NAT en iptables que permite el acceso directo desde cualquier host en la LAN.
  3. Falta de políticas de firewall a nivel de contenedor – UFW o firewalld operan sobre la interfaz del host (docker0), pero la dirección IP del contenedor puede cambiar, lo que invalida reglas estáticas.

Estos puntos hacen que la seguridad dependa de la configuración manual de puertos y de la constancia de las IPs, algo frágil en entornos donde los contenedores se recrean con frecuencia.

Solución

La estrategia más robusta combina redes Docker aisladas, exposición únicamente a través del reverse proxy y políticas de firewall basadas en etiquetas de red. El flujo típico es:

  1. Crear una red interna para cada grupo de servicios (por ejemplo frontend, backend, db). Cada red es privada; los contenedores solo pueden comunicarse con los que comparten la misma red.
  2. Colocar el reverse proxy (NGINX, Traefik, Caddy…) en una red “exposed” que sí tenga puertos publicados al host. El proxy también se conecta a las redes internas de los servicios que debe enrutar.
  3. Eliminar la directiva ports de los servicios que no deben ser accesibles directamente. En su lugar, usan depends_on y la referencia de nombre de contenedor (service_name:port) dentro de la red interna.
  4. Aplicar reglas de firewall que bloqueen todo el tráfico entrante a docker0 y permitan solo la interfaz del proxy. Con UFW se puede usar la etiqueta de red (docker0) o, de forma más precisa, reglas de iptables que acepten tráfico desde la IP del contenedor proxy y lo descarten para el resto.
  5. Opcional: usar docker network inspect y etiquetas para generar reglas dinámicas con scripts que actualicen iptables cada vez que Docker crea o elimina una red.

Paso a paso

1. Definir redes en docker‑compose.yml

networks:
  frontend:
  backend:
  db:
  exposed:
    driver: bridge
    ipam:
      config:
        - subnet: 172.25.0.0/24   # red que será publicada al host

2. Configurar el reverse proxy

services:
  reverse-proxy:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    networks:
      - exposed
      - frontend
      - backend
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro

3. Conectar cada aplicación a su red interna

  app-web:
    image: my/webapp
    networks:
      - frontend
    # sin `ports`, solo accesible vía proxy

  app-api:
    image: my/api
    networks:
      - backend
    # sin `ports`

  db:
    image: postgres:15
    networks:
      - db
    environment:
      POSTGRES_PASSWORD: secret

4. Bloquear acceso directo con UFW (o iptables)

# Permitir solo al proxy (IP estática dentro de la red `exposed`)
PROXY_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -qf "name=reverse-proxy"))
sudo ufw deny in on docker0 to any
sudo ufw allow in on docker0 from $PROXY_IP to any

Si la IP del proxy cambia, un pequeño cron o systemd‑timer que vuelva a ejecutar el fragmento mantiene la regla actualizada.

5. Validar que los puertos no estén publicados

docker compose ps | grep -i "0.0.0.0"

No debería aparecer ninguna entrada para los servicios internos.

Cuándo aplicar esta solución

  • Entornos multi‑servicio donde varios contenedores deben comunicarse entre sí pero no con la LAN.
  • Homelabs o servidores de pruebas que usan una única máquina física y desean evitar “cross‑talk” accidental.
  • Escenarios de LAN party, eventos o workshops donde varios usuarios comparten la misma red y no se confía en la segmentación de VLAN del router.
  • No es necesario cuando cada contenedor ya está aislado por máquinas virtuales o cuando se usa un orquestador con políticas de red avanzadas (Kubernetes NetworkPolicies).

No se recomienda en entornos donde los contenedores deben ser accesibles directamente desde la red externa (por ejemplo, bases de datos expuestas a clientes externos) sin un proxy dedicado.

Código

# 1. Crear redes aisladas
docker network create frontend
docker network create backend
docker network create db
docker network create --subnet 172.25.0.0/24 exposed

# 2. Lanzar compose (asume archivo docker-compose.yml preparado)
docker compose up -d

# 3. Configurar UFW dinámico
PROXY_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -qf "name=reverse-proxy"))
sudo ufw deny in on docker0 to any
sudo ufw allow in on docker0 from $PROXY_IP to any

Verificación

  1. Comprobar que solo el proxy escucha en el host

    sudo ss -tlnp | grep -E '80|443'
    

    La salida debe mostrar nginx (o el contenedor del proxy) y nada más.

  2. Intentar acceso directo a un contenedor interno
    Desde otro host de la LAN, ejecuta:

    curl http://<IP_DEL_HOST>:8080   # puerto que habría usado app-web
    

    La petición debe fallar con “Connection refused”.

  3. Acceder vía proxy

    curl http://<IP_DEL_HOST>/web   # ruta configurada en NGINX
    

    Debería devolver la respuesta del contenedor app-web.

  4. Revisar reglas de firewall

    sudo ufw status numbered | grep docker0
    

    Debe aparecer una regla “ALLOW IN” solo desde la IP del proxy y una regla “DENY IN” para el resto.

Notas adicionales

  • IP estática para el proxy: asignar una IP fija dentro de la red exposed evita que el script de UFW tenga que buscarla cada vez. Se hace con la opción ipam en la definición de la red.
  • Traefik como alternativa: Traefik detecta automáticamente contenedores en redes Docker y configura rutas sin necesidad de archivos de configuración estáticos.
  • Persistencia de reglas: UFW guarda las reglas en /etc/ufw/user.rules. Si prefieres iptables puro, guarda el script en /etc/rc.local o usa iptables‑persistent.
  • Monitorización: docker network ls y docker network inspect son útiles para auditar que no haya contenedores conectados a la red exposed sin pasar por el proxy.
  • Escalabilidad: para cientos de servicios, agrupa redes por dominio de negocio (frontend, api, jobs) y conecta el proxy a todas ellas. El número de reglas de firewall sigue siendo constante porque solo se controla la interfaz del proxy.