Docker run o Docker Compose: cuándo basta un comando y cuándo necesitas un compose.yaml

Casi todo el mundo empieza con docker run: un comando, un contenedor, resultado inmediato. El problema llega cuando el comando ya ocupa cuatro líneas, hay una base de datos que tiene que arrancar antes que la aplicación y alguien del equipo pregunta cómo se levanta todo el entorno. Ahí aparece Docker Compose. Este artículo compara las dos herramientas con el mismo caso (una aplicación con PostgreSQL), traduce los flags de docker run a su equivalente en compose.yaml y deja claro dónde acaba cada una.

Qué hace cada una

docker run crea e inicia un contenedor a partir de una imagen. Todo lo que necesita (puertos, volúmenes, variables, red, política de reinicio) se pasa como flags en esa invocación. No hay nada guardado: si no apuntas el comando en alguna parte, la configuración vive solo en tu historial de la terminal.

Docker Compose describe en un fichero YAML un conjunto de servicios, redes y volúmenes, y los gestiona como una unidad: docker compose up lo crea todo, docker compose down lo retira. El fichero se versiona con el código, así que el entorno es reproducible. Hoy Compose es un plugin del propio cliente Docker, y se invoca con un espacio, docker compose. El antiguo binario independiente docker-compose (versión 1, escrito en Python) dejó de recibir soporte en 2023, y conviene no mezclarlo con la sintaxis actual.

El caso de partida: aplicación y PostgreSQL con docker run

Para tener algo concreto, este sería el montaje de una aplicación propia (imagen miapp) que habla con PostgreSQL 18 usando solo docker run:

# 1. Red y volumen propios
docker network create appnet
docker volume create pgdata

# 2. Base de datos con comprobación de salud
docker run -d --name db 
  --network appnet 
  -e POSTGRES_USER=app 
  -e POSTGRES_PASSWORD=cambia-esto 
  -e POSTGRES_DB=appdb 
  -v pgdata:/var/lib/postgresql 
  --health-cmd "pg_isready -U app -d appdb" 
  --health-interval 5s 
  --health-timeout 3s 
  --health-retries 5 
  --restart unless-stopped 
  postgres:18

# 3. Esperar a que la base de datos esté sana
until [ "$(docker inspect -f '{{.State.Health.Status}}' db)" = "healthy" ]; do
  sleep 1
done

# 4. La aplicación
docker build -t miapp .
docker run -d --name app 
  --network appnet 
  -p 127.0.0.1:3000:3000 
  -e DATABASE_URL=postgres://app:cambia-esto@db:5432/appdb 
  --restart unless-stopped 
  miapp

Funciona, pero fíjate en lo que ha tenido que hacer el script a mano: crear la red, crear el volumen, ordenar el arranque y esperar a la salud de la base de datos con un bucle. Para apagarlo hay que acordarse de parar y borrar cada contenedor, y de decidir qué pasa con la red y el volumen. Dos detalles técnicos que explican el diseño:

  • La resolución de nombres entre contenedores (db en la URL de conexión) solo funciona en redes definidas por el usuario. En la red bridge por defecto los contenedores no se encuentran por nombre.
  • Desde PostgreSQL 18 la imagen oficial declara el volumen en /var/lib/postgresql y usa un PGDATA que depende de la versión mayor. En la 17 y anteriores el punto de montaje era /var/lib/postgresql/data. Si montas la ruta equivocada, los datos no persisten.

Equivalencias flag por flag

Casi todo lo que se pasa en la línea de comandos tiene su clave en Compose. Esta es la tabla de traducción de los flags más usados:

docker run

compose.yaml

Notas

-p 127.0.0.1:3000:3000

ports: ["127.0.0.1:3000:3000"]

Entrecomilla siempre los puertos; YAML puede interpretar algunos valores como números en base 60.

-v pgdata:/var/lib/postgresql

volumes: [pgdata:/var/lib/postgresql]

Los volúmenes con nombre se declaran además en la clave raíz volumes:.

-e CLAVE=valor

environment:

Admite mapa o lista. Una clave sin valor se toma del entorno del shell.

--env-file .env.app

env_file:

Si hay conflicto, gana environment sobre env_file.

--network appnet

networks:

Sin esta clave, los servicios se unen a una red default creada por Compose.

--network-alias api

networks.<red>.aliases

El nombre del servicio ya funciona como alias de forma automática.

--restart unless-stopped

restart: unless-stopped

Valores: "no" (por defecto, con comillas), always, on-failure[:max], unless-stopped.

--health-cmd, --health-interval, --health-timeout, --health-retries, --health-start-period

healthcheck: con test, interval, timeout, retries, start_period

Compose añade start_interval, útil durante el periodo de arranque.

--name db

container_name: db

Mejor no usarlo: por defecto Compose nombra los contenedores proyecto-servicio-1 y así permite escalar réplicas.

-d

docker compose up -d

No es una clave del fichero, sino un modificador del comando.

--rm

docker compose run --rm servicio

Para tareas puntuales, no para servicios permanentes.

El mismo montaje en un compose.yaml

Este es el equivalente del script anterior en un fichero compose.yaml (nombre preferido; docker-compose.yml también se sigue reconociendo). Fíjate en que no lleva la clave version: está obsoleta, Compose la ignora y emite una advertencia si la encuentra.

name: miapp

services:
  app:
    build: .
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD:?define DB_PASSWORD en el fichero .env}@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy
        restart: true
    restart: unless-stopped

  db:
    image: postgres:18
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD:?define DB_PASSWORD en el fichero .env}
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s
    restart: unless-stopped

volumes:
  pgdata:

Y, junto al fichero, un .env con el secreto, fuera del control de versiones:

DB_PASSWORD=cambia-esto

Con eso, arrancarlo todo es una línea: docker compose up -d --wait. La opción --wait bloquea hasta que los servicios estén en marcha y, si tienen healthcheck, sanos, lo que sustituye al bucle con docker inspect.

Qué ha cambiado respecto al script

  • Red automática. Compose crea una red para el proyecto y registra cada servicio con su nombre, así que db resuelve igual que antes sin escribir ningún docker network create.
  • Orden de arranque con condición. En la forma corta, depends_on: [db] solo garantiza que el contenedor se haya iniciado, no que la base de datos acepte conexiones. La forma larga con condition: service_healthy espera a que el healthcheck pase. Las otras condiciones son service_started y service_completed_successfully (útil para tareas de migración que deben terminar bien). La opción restart: true hace que Compose reinicie app cuando él mismo reinicie db tras una actualización; requiere Compose 2.17 o superior.
  • Doble dólar. Compose interpola ${VARIABLE} al leer el fichero. Para que la expansión ocurra dentro del contenedor, no en tu máquina, hay que escribir $$. Por eso el healthcheck usa $${POSTGRES_USER}.
  • Nombres de proyecto. Volúmenes y redes llevan el prefijo del proyecto (aquí miapp_pgdata), y la clave name fija ese prefijo en lugar de depender del nombre del directorio.

Una limitación del service_healthy que conviene conocer: solo ordena el arranque. Si la base de datos se cae más tarde, Compose no pausa la aplicación; esa resiliencia (reconexión con reintentos) debe estar en el código de la aplicación.

Variables de entorno y el fichero .env

Compose lee automáticamente el .env del directorio del proyecto para interpolar variables en el YAML. La precedencia, de mayor a menor, es: variables del shell, ficheros indicados con --env-file y, por último, el .env por defecto. Pasar --env-file sustituye al .env habitual, y si el fichero no existe, Compose devuelve error.

La sintaxis de interpolación admite valores por defecto y validaciones:

  • ${VAR:-por-defecto}: usa por-defecto si VAR no está definida o está vacía.
  • ${VAR-por-defecto}: solo si no está definida.
  • ${VAR:?mensaje}: aborta con ese mensaje si falta o está vacía. Es lo que usa el ejemplo para no arrancar sin contraseña.
  • ${VAR:+reemplazo}: devuelve reemplazo solo si VAR tiene valor.

Para comprobar qué ha entendido Compose antes de arrancar nada, docker compose config imprime el fichero con todo interpolado y valida la sintaxis. Es el primer comando que conviene ejecutar cuando algo no se comporta como esperas.

Sobre secretos: una variable de entorno es cómoda, pero queda visible en docker inspect. La imagen oficial de PostgreSQL admite el sufijo _FILE (por ejemplo, POSTGRES_PASSWORD_FILE) para leer el valor desde un fichero, normalmente montado como secreto en /run/secrets/. Para un entorno de desarrollo basta con .env; para uno compartido, los secretos de Compose son la opción más limpia.

Perfiles: servicios opcionales en el mismo fichero

No todos los servicios deben arrancar siempre. Un panel de administración de la base de datos o un contenedor de migraciones no tienen por qué levantarse con docker compose up. Los perfiles resuelven eso: un servicio con profiles solo arranca si activas ese perfil, y los que no tienen la clave arrancan siempre.

services:
  app:
    build: .
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:18
    # ... configuración como en el ejemplo anterior

  adminer:
    image: adminer
    ports:
      - "127.0.0.1:8080:8080"
    depends_on:
      - db
    profiles: [debug]

  migraciones:
    build: .
    command: ["npm", "run", "migrate"]
    depends_on:
      db:
        condition: service_healthy
    profiles: [tools]
# Solo app y db
docker compose up -d

# Añade Adminer
docker compose --profile debug up -d

# Equivalente con variable de entorno
COMPOSE_PROFILES=debug docker compose up -d

# Ejecutar las migraciones una vez, sin activar el perfil tools
docker compose run --rm migraciones

Dos matices: docker compose run sobre un servicio con perfil lo arranca aunque el perfil no esté activo, y sus dependencias se levantan con él. Y un docker compose down sin más solo detiene los servicios sin perfil; para parar los de un perfil hay que activarlo (--profile debug down) o nombrar el servicio.

Comandos de día a día

docker compose up -d --build   # crea y arranca, reconstruyendo imágenes si cambió el código
docker compose ps              # estado y salud de cada servicio
docker compose logs -f app     # logs en directo de un servicio
docker compose exec db psql -U app appdb   # shell dentro de un servicio en marcha
docker compose down            # para y elimina contenedores y red; conserva los volúmenes
docker compose down -v         # igual, pero BORRA también los volúmenes (se pierden los datos)

La diferencia entre down y down -v es la que más sustos da: el segundo elimina el volumen de PostgreSQL con todos sus datos. Úsalo cuando quieras una base de datos limpia a propósito, nunca por inercia.

Cuándo basta docker run

  • Un solo contenedor. Probar una imagen, lanzar una base de datos desechable para una prueba o ejecutar una herramienta con --rm.
  • Utilidades de línea de comandos. Un linter, un conversor o una compilación puntual en un contenedor, sin estado que mantener.
  • Un script de despliegue que ya existe y funciona, con uno o dos contenedores. Reescribirlo no aporta nada si nadie lo toca.
  • Dependencias de CI muy simples. Un servicio auxiliar levantado con un comando antes de ejecutar los tests.

Cuándo dar el salto a Compose

  • Más de un contenedor que se hablan entre sí (aplicación, base de datos, caché, cola).
  • Orden de arranque que importa y que quieres expresar con healthchecks en lugar de bucles de espera.
  • Un entorno que otros tienen que levantar: cualquiera clona el repositorio y ejecuta docker compose up. El fichero hace de documentación ejecutable.
  • Entornos de desarrollo y pruebas de integración en CI, con perfiles para servicios opcionales y volúmenes que se crean y destruyen con el proyecto.
  • Despliegues en un único servidor. Es una opción legítima: un servidor con Compose, restart: unless-stopped y copias de seguridad de los volúmenes cubre muchas aplicaciones pequeñas y medianas sin la complejidad de un orquestador.

Cuándo Compose se queda corto

Compose gestiona contenedores en un host. En cuanto necesitas más de uno, empiezan los límites: no reparte réplicas entre máquinas, no reubica contenedores si un nodo falla, no hace despliegues progresivos con vuelta atrás automática ni escala según la carga. Si tus requisitos incluyen alta disponibilidad real, autoescalado, actualizaciones sin corte o decenas de servicios con equipos distintos, el siguiente paso es un orquestador, y en la práctica eso significa Kubernetes (o un servicio gestionado basado en él).

La transición no empieza de cero: un compose.yaml bien escrito ya contiene casi toda la información que Kubernetes necesita (imagen, puertos, variables, volúmenes, healthchecks). Herramientas de conversión como Kompose parten de ese fichero, aunque el resultado conviene revisarlo a mano antes de llevarlo a producción.

Recomendación práctica

La regla útil no es «Compose siempre» ni «docker run siempre»: es mirar cuántos comandos docker run tienes que recordar. Si es uno, déjalo así. Si son dos o más con red y volúmenes compartidos, escribe el compose.yaml, valida con docker compose config y versiona el fichero con el código. Y si lo que te preocupa es qué pasa cuando cae un servidor entero, Compose ya no es la herramienta adecuada.

Si trabajas con Docker en otros lenguajes, tienes más contexto en estos artículos: Dockerizar aplicaciones Python con Dockerfile optimizado, multi-stage y Compose, Docker con PHP: php-fpm, Nginx y docker-compose con MySQL y un emulador Android dentro de Docker para pruebas móviles y CI/CD.

Imagen: Pexels / panumas nikhomkhai

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP