Francisco Javier Palacios PérezFco. Javier Palacios Pérez
Desarrollador de software
Networking en Docker Compose: redes, servicios y aislamiento

Networking en Docker Compose: redes, servicios y aislamiento

Networking en Docker Compose: redes, servicios y aislamiento

Networking en Docker Compose: redes, servicios y aislamiento

Hay un momento muy Docker Compose: tienes Postgres arrancado, la API arrancada, los logs parecen razonables, y aun así la API dice que no puede conectar a localhost:5432.

Miras docker compose ps. Postgres está Up. Miras el docker-compose.yml. El puerto está ahí. Miras la pantalla con esa mezcla de sospecha y cansancio que solo aparece cuando una herramienta te está diciendo técnicamente la verdad, pero de la forma menos útil posible.

¿Está Postgres caído? No. ¿Está mal el puerto? Puede, pero seguramente no. ¿Es localhost una trampa con buena reputación? Exacto.

En Docker Compose, cada contenedor vive en su propio pequeño mundo de red. Si entiendes eso, el resto deja de parecer magia negra con YAML.

La red por defecto de Compose

Cuando ejecutas docker compose up, Compose crea automáticamente una red para el proyecto. Si tu carpeta se llama myapp, esa red suele llamarse algo como myapp_default.

Todos los servicios del docker-compose.yml se conectan a esa red por defecto.

services:
  api:
    image: myapp/api

  db:
    image: postgres:16-alpine

Aunque no hayas escrito networks: en ninguna parte, Compose ha hecho trabajo por ti:

docker compose up -d
docker network ls

Verás una red parecida a esta:

NETWORK ID     NAME            DRIVER    SCOPE
abc123def456   myapp_default   bridge    local

Dentro de esa red, los servicios pueden encontrarse usando su service name. La API puede conectar a Postgres usando db como hostname.

postgresql://myapp:secret@db:5432/myapp

No uses la IP del contenedor. Las IPs pueden cambiar cuando recreas servicios. Usar IPs de contenedor en Compose es como apuntar en una servilleta dónde aparcaste el coche hace tres semanas: técnicamente fue información útil durante unos minutos.

Usa nombres de servicio.

localhost dentro de un contenedor no es tu máquina

Aquí está la trampa principal.

Desde tu máquina, localhost significa tu máquina.

Desde dentro de un contenedor, localhost significa ese contenedor.

services:
  api:
    image: myapp/api
    environment:
      DATABASE_URL: postgresql://myapp:secret@localhost:5432/myapp

  db:
    image: postgres:16-alpine

Eso está mal para comunicación entre contenedores. La API buscará Postgres dentro del propio contenedor de la API. Spoiler: no está ahí. Si lo estuviera, tendrías otro problema, probablemente con acento de arquitectura improvisada.

La versión correcta usa el nombre del servicio:

services:
  api:
    image: myapp/api
    environment:
      DATABASE_URL: postgresql://myapp:secret@db:5432/myapp

  db:
    image: postgres:16-alpine

La regla mental:

Desde dónde conectasHost correcto
Tu navegador al APIlocalhost si publicaste el puerto
API a Postgresdb
API a Redisredis
Un contenedor a otroEl service name

No te agobies si esto te parece contraintuitivo al principio. localhost lleva años pareciendo una palabra inocente mientras arruina tardes enteras con una sonrisa.

ports no es para que los contenedores se hablen

Otra confusión clásica: creer que necesitas publicar un puerto para que otro contenedor pueda conectarse.

No.

services:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://myapp:secret@db:5432/myapp

  db:
    image: postgres:16-alpine

El ports: "3000:3000" publica el puerto de la API hacia tu máquina. Sirve para que puedas abrir http://localhost:3000 desde el navegador.

Pero la API no necesita que Postgres tenga ports: "5432:5432" para conectar con db:5432. Ambos servicios están en la misma red interna de Compose.

Esto es importante por seguridad. Si publicas Postgres así:

services:
  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"

Estás exponiendo la base de datos al host. En desarrollo puede ser útil si quieres conectar con TablePlus, DBeaver, psql local o cualquier herramienta externa. Pero no lo hagas “por si acaso”. El “por si acaso” en infraestructura es una forma elegante de decir “aquí dejo una puerta abierta y ya veremos”.

Si solo la API necesita hablar con la base de datos, no publiques el puerto.

Redes personalizadas: separar habitaciones

La red por defecto está bien para proyectos pequeños. Todos los servicios se ven entre sí y listo.

Pero en una aplicación algo más realista, quizá tienes:

  • web: frontend o reverse proxy.
  • api: backend.
  • db: base de datos.
  • redis: caché.

No hay ninguna razón para que web pueda conectar directamente a db. El frontend habla con la API. La API habla con la base de datos. La base de datos no debería estar en el portal saludando a todo el edificio.

Puedes modelarlo con redes personalizadas:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    networks:
      - frontend

  api:
    image: myapp/api
    networks:
      - frontend
      - backend

  db:
    image: postgres:16-alpine
    networks:
      - backend

networks:
  frontend:
  backend:

Ahora las reglas son claras:

ServicioRedesPuede hablar con
webfrontendapi
apifrontend, backendweb, db
dbbackendapi

web y db no comparten red, así que no pueden resolverse por nombre ni conectarse directamente. Esto no sustituye autenticación, firewall, permisos ni sentido común, pero reduce superficie. Y reducir superficie es una de esas cosas aburridas que te ahorran incidentes interesantes. Los incidentes interesantes están sobrevalorados.

expose vs ports

Compose también tiene expose:

services:
  api:
    image: myapp/api
    expose:
      - "3000"

expose documenta que un servicio escucha en un puerto para otros servicios de la red, pero no lo publica en tu máquina.

En la práctica, los servicios en la misma red pueden conectar al puerto del contenedor aunque no escribas expose. Por eso muchos equipos ni lo usan. Puede ser útil como documentación dentro del docker-compose.yml, pero no lo confundas con seguridad.

La diferencia importante:

CampoPublica al hostUso principal
portsSíAcceder desde tu máquina o desde fuera
exposeNoDocumentar puertos internos

Si quieres abrir algo desde el navegador, usa ports. Si solo se hablan contenedores, normalmente no necesitas nada.

External networks

A veces quieres que varios proyectos Compose compartan una red. Por ejemplo, un reverse proxy común (traefik, nginx, caddy) que enruta varias aplicaciones locales o varios servicios en un mismo servidor.

Primero creas la red manualmente:

docker network create shared-proxy

Luego la declaras como externa:

services:
  app:
    image: myapp/api
    networks:
      - shared-proxy

networks:
  shared-proxy:
    external: true

external: true significa: “Compose, no crees esta red; ya existe, úsala”.

Si la red no existe, Compose fallará. Y eso está bien. Mejor fallar que inventarse una red nueva y dejarte media hora preguntándote por qué el proxy no ve tu aplicación. Docker ya tiene suficiente material para confundirte sin ayuda extra.

Network aliases

Por defecto, cada servicio se resuelve por su nombre: api, db, redis.

A veces quieres nombres adicionales. Ahí entran los aliases.

services:
  api:
    image: myapp/api
    networks:
      backend:
        aliases:
          - api-service
          - internal-api

  worker:
    image: myapp/worker
    networks:
      - backend

networks:
  backend:

Desde worker, estos hostnames apuntan al servicio api:

api
api-service
internal-api

Úsalo con cuidado. Un alias puede ayudar cuando una aplicación legacy espera un hostname concreto. Pero si empiezas a poner tres nombres por servicio “por comodidad”, acabarás con un pequeño DNS doméstico dentro del YAML. Como tener cinco motes para la misma persona en un grupo de WhatsApp: divertido hasta que alguien nuevo intenta entender la conversación.

Ejemplo completo

Un stack razonable con reverse proxy, API, base de datos y caché:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    networks:
      - frontend

  api:
    build: ./api
    environment:
      DATABASE_URL: postgresql://myapp:secret@db:5432/myapp
      REDIS_URL: redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - frontend
      - backend

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - backend

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3
    networks:
      - backend

volumes:
  db-data:

networks:
  frontend:
  backend:

Fíjate en tres detalles:

  • Solo web publica un puerto al host.
  • api habla con db y redis usando service names.
  • db y redis no están en frontend, así que web no puede hablarles directamente.

Esto no convierte tu aplicación en Fort Knox. Pero ya no estás poniendo todos los servicios en la misma sala con una etiqueta que dice “sed buenos”.

Reto práctico

Coge el docker-compose.yml del tutorial anterior y sepáralo en dos redes: frontend y backend. La API debe estar en ambas. Postgres y Redis solo en backend. Si tienes un servicio web o proxy, ponlo solo en frontend y publica únicamente su puerto.

Después verifica que tus URLs internas usan service names (db, redis, api) y no localhost. Si encuentras un localhost dentro de una variable de entorno entre contenedores, no lo mires con cariño: cámbialo.


Con esto ya tienes el mapa mental correcto: Compose crea una red por defecto, los servicios se descubren por nombre, ports es para salir hacia tu máquina, y las redes personalizadas te permiten aislar lo que no debería hablar directamente. En el siguiente tutorial cerraremos el módulo de Compose con buenas prácticas: ficheros por entorno, secrets, resource limits, logging y esos pequeños detalles que separan un docker-compose.yml útil de uno que parece escrito durante un apagón.

¡Nunca dejes de programar!