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 (
dben la URL de conexión) solo funciona en redes definidas por el usuario. En la redbridgepor defecto los contenedores no se encuentran por nombre. - Desde PostgreSQL 18 la imagen oficial declara el volumen en
/var/lib/postgresqly usa unPGDATAque 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 |
|
| Entrecomilla siempre los puertos; YAML puede interpretar algunos valores como números en base 60. |
|
| Los volúmenes con nombre se declaran además en la clave raíz |
|
| Admite mapa o lista. Una clave sin valor se toma del entorno del shell. |
|
| Si hay conflicto, gana |
|
| Sin esta clave, los servicios se unen a una red |
|
| El nombre del servicio ya funciona como alias de forma automática. |
|
| Valores: |
|
| Compose añade |
|
| Mejor no usarlo: por defecto Compose nombra los contenedores |
|
| No es una clave del fichero, sino un modificador del comando. |
|
| 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
dbresuelve igual que antes sin escribir ningúndocker 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 concondition: service_healthyespera a que el healthcheck pase. Las otras condiciones sonservice_startedyservice_completed_successfully(útil para tareas de migración que deben terminar bien). La opciónrestart: truehace que Compose reinicieappcuando él mismo reiniciedbtras 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 clavenamefija 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}: usapor-defectosiVARno 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}: devuelvereemplazosolo siVARtiene 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-stoppedy 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
