Una aplicación de verdad casi nunca es un contenedor solo: la web, la API, la base de datos… Con
docker run habría que crear la red, los volúmenes y cada contenedor a mano, con todas sus
opciones, y en el orden bueno. Docker Compose lo escribe todo en un archivo, compose.yaml,
y lo levanta (o lo baja) con una orden.
Los ejercicios pasan en el servidor de pruebas de un club de fútbol: la web de los socios, la
API de las entradas y la base de datos, en ~/club.
Un compose.yaml por dentro
tecnico@srv-pruebas:~/club$ cat compose.yaml services: web: image: nginx:1.27-alpine ports: - "8080:80" db: image: postgres:latest environment: POSTGRES_PASSWORD: Gol-2026
Cada entrada de services es un servicio: un contenedor con su configuración. Lo que va
dentro es lo mismo que pondrías en docker run, con otro nombre:
| En el compose.yaml | En docker run |
|---|---|
image: nginx:1.27-alpine |
la imagen |
ports: ["8080:80"] |
-p 8080:80 |
environment: |
-e CLAVE=valor |
volumes: ["datos:/ruta"] |
-v datos:/ruta |
command: ["sleep", "86400"] |
lo de después de la imagen |
restart: always |
--restart always |
docker compose up
up -d lo levanta todo en segundo plano (sin -d, se queda enganchado a los logs):
tecnico@srv-pruebas:~/club$ docker compose up -d [+] Running 3/3 ✔ Network club_default Created 0.0s ✔ Container club-web-1 Started 1.0s ✔ Container club-db-1 Started 2.0s
El proyecto se llama como la carpeta (club), y de ahí sale todo lo demás: una red para el
proyecto (club_default) y un contenedor por servicio (club-web-1, club-db-1). Son
contenedores normales: docker ps también los ve.
docker compose ps enseña solo los del proyecto, con la columna del servicio:
tecnico@srv-pruebas:~/club$ docker compose ps NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS club-db-1 postgres:latest "docker-entrypoint.s…" db 9 seconds ago Up 8 seconds 5432/tcp club-web-1 nginx:1.27-alpine "/docker-entrypoint.…" web 10 seconds ago Up 9 seconds 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
Y docker compose logs junta los de todos, cada línea con el servicio delante (o los de uno,
con su nombre):
tecnico@srv-pruebas:~/club$ docker compose logs db | tail -2 db-1 | 2026-11-04 10:00:27.265 UTC [72] LOG: database system was shut down at 2026-10-02 12:52:48 UTC db-1 | 2026-11-04 10:00:27.269 UTC [1] LOG: database system is ready to accept connections
Volver a lanzar up -d no estropea lo que ya va: crea lo que falta, arranca lo parado (con
docker compose stop) y solo recrea los servicios que han cambiado en el compose.yaml.
Ejercicio
Todo con una orden
En ~/club está el compose.yaml del club: la web y la base de datos. Levanta el proyecto en segundo plano, mira cómo están sus contenedores y lo que ha dicho la base al arrancar.
Preparando la máquina…
Escribir uno
Un compose.yaml es YAML: la sangría (con espacios, nunca tabuladores) dice qué va dentro de
qué, y los guiones son los elementos de una lista. Los puertos, entre comillas: "8080:80" es un
texto, como lo quiere Compose.
tecnico@srv-pruebas:~/tienda$ printf 'services:\n web:\n image: nginx:1.27-alpine\n ports:\n - "8081:80"\n' > compose.yaml
Si algo está mal escrito, Compose lo dice antes de hacer nada:
tecnico@srv-pruebas:~/club$ docker compose up -d validating /home/tecnico/club/compose.yaml: services.web additional properties 'port' not allowed
(Era ports, no port.) docker compose config enseña el archivo tal como lo entiende Compose:
lo primero que mirar cuando algo no sale como esperabas.
Ejercicio
Tu propio compose.yaml
La tienda del club aún no tiene compose.yaml. Escribe ~/tienda/compose.yaml con un servicio web (nginx:1.27-alpine, publicado en el puerto 8081 de la máquina) y levántalo.
Preparando la máquina…
Los servicios se encuentran por su nombre
En la red del proyecto, cada servicio se encuentra por su nombre: desde la API, la base es
db. docker compose exec entra en el contenedor de un servicio:
tecnico@srv-pruebas:~/club$ docker compose exec api ping -c 1 db PING db (172.18.0.3): 56 data bytes 64 bytes from 172.18.0.3: seq=0 ttl=64 time=0.013 ms --- db ping statistics --- 1 packets transmitted, 1 packets received, 0% packet loss round-trip min/avg/max = 0.013/0.013/0.013 ms
Tras cambiar el compose.yaml, docker compose up -d otra vez: recrea los servicios que han
cambiado y deja los demás como están (Running):
tecnico@srv-pruebas:~/club$ docker compose up -d [+] Running 3/3 ✔ Container club-web-1 Running 0.0s ✔ Container club-db-1 Running 0.0s ✔ Container club-api-1 Started 1.0s
Ejercicio
Por su nombre
La API del club busca la base en DB_HOST=localhost, pero dentro de su contenedor localhost es ella misma. Comprueba que llega a la base por el nombre del servicio, cambia DB_HOST y vuelve a levantar el proyecto.
Preparando la máquina…
Bajar el proyecto y los datos
docker compose down para y borra los contenedores y la red del proyecto:
tecnico@srv-pruebas:~/club$ docker compose down [+] Running 4/4 ✔ Container club-web-1 Removed 1.0s ✔ Container club-db-1 Removed 1.0s ✔ Container club-api-1 Removed 11.0s ✔ Network club_default Removed 1.0s
La base no ha guardado sus datos en el contenedor, sino en un volumen anónimo (lo pide la imagen
de postgres). down no lo borra, pero el siguiente up crea una base nueva con otro volumen
anónimo vacío, y los datos de antes se quedan en uno que nadie usa. Para que la base vuelva a
encontrarlos, un volumen con nombre: en el servicio, dónde se monta; abajo, en volumes:,
que existe.
services:
db:
image: postgres:latest
volumes:
- datos:/var/lib/postgresql
volumes:
datos:
El volumen se llama como el proyecto y su clave (club_datos), y down no lo toca:
tecnico@srv-pruebas:~/club$ docker volume ls DRIVER VOLUME NAME local club_datos
Ejercicio
Que los datos sobrevivan
La base del club guarda sus datos dentro del contenedor: un docker compose down y se pierden. Ponle un volumen con nombre (datos, en /var/lib/postgresql), vuelve a levantarla y comprueba que el volumen sigue tras bajar y subir el proyecto.
Preparando la máquina…
Primero la base: depends_on y healthcheck
depends_on dice qué servicios se arrancan antes. Pero «arrancado» no es «listo»: postgres tarda
unos segundos en aceptar conexiones, y una API que arranca justo después falla. Un
healthcheck le dice a Docker cómo comprobar que el servicio está bien; con
condition: service_healthy, Compose espera a que lo esté:
services:
db:
image: postgres:latest
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 2s
api:
image: alpine:3.20
depends_on:
db:
condition: service_healthy
tecnico@srv-pruebas:~/club$ docker compose up -d [+] Running 4/4 ✔ Network club_default Created 0.0s ✔ Volume "club_datos" Created 0.0s ✔ Container club-db-1 Healthy 3.0s ✔ Container club-api-1 Started 4.0s
tecnico@srv-pruebas:~/club$ docker compose ps NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS club-api-1 alpine:3.20 "sleep 86400" api 11 seconds ago Up 8 seconds club-db-1 postgres:latest "docker-entrypoint.s…" db 12 seconds ago Up 11 seconds (healthy) 5432/tcp
Mientras se comprueba, el estado dice (health: starting); si falla las veces de retries
(3, si no se dice), (unhealthy), y lo que depende de él no arranca.
Ejercicio
Primero la base
La API arranca en cuanto se crea el contenedor de la base, aunque esta aún no acepte conexiones. Ponle a la base un healthcheck con pg_isready y haz que la API espere a que esté healthy; luego, levanta el proyecto.
Preparando la máquina…
Variables y el .env
Las contraseñas no se escriben en el compose.yaml, que va al repositorio con el código. Se
escribe ${VARIABLE} y Compose la busca en el entorno de quien lo lanza o en el archivo .env
de la carpeta (que no se sube). Solo el que se llama exactamente .env: un tienda.env o un
claves.txt al lado no lo lee. Por ejemplo:
tecnico@srv-pruebas:~/club$ echo DB_CLAVE=Gol-2026 > .env
Si no la encuentra, lo avisa y la deja vacía:
tecnico@srv-pruebas:~/club$ docker compose config WARN[0000] The "DB_CLAVE" variable is not set. Defaulting to a blank string. name: club services: … db: environment: POSTGRES_PASSWORD: ""
${VARIABLE:-valor} da un valor por defecto, y ${VARIABLE:?mensaje} hace que Compose se pare
con ese mensaje si falta.
Ejercicio
La clave, fuera del compose
La contraseña de la base está escrita en el compose.yaml, que se sube al repositorio del club. Llévala a ~/club/.env (como DB_CLAVE), que el compose la use con ${DB_CLAVE} y vuelve a levantar el proyecto: la base, con la misma clave.
Preparando la máquina…
¿Qué falla?
Un servicio con restart: always que se cae nada más arrancar no se queda parado: Docker lo
vuelve a arrancar una y otra vez, esperando un poco más cada vez. ps lo enseña reiniciándose:
tecnico@srv-pruebas:~/club$ docker compose ps NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS club-api-1 alpine:3.20 "sh -c 'echo Falta D…" api 22 seconds ago Restarting (1) 8 seconds ago
El número es el código con el que terminó. Lo que dijo antes de caerse, en sus logs:
tecnico@srv-pruebas:~/club$ docker compose logs api | tail -3 api-1 | Falta DB_HOST api-1 | Falta DB_HOST api-1 | Falta DB_HOST
Se arregla en el compose.yaml (aquí, con la variable que le falta) y docker compose up -d.
Si up dice port is already allocated, ese puerto de la máquina ya lo publica otro contenedor
(docker ps enseña cuál), y dos no pueden publicar el mismo: en ports se cambia el de la
izquierda, el de la máquina.
Resumen
| Orden | Para qué |
|---|---|
docker compose up -d |
levantar el proyecto (o lo que ha cambiado) |
docker compose ps |
sus contenedores y cómo están |
docker compose logs [servicio] |
lo que han escrito |
docker compose exec servicio orden |
ejecutar algo dentro de un servicio |
docker compose stop · start · restart |
parar y arrancar sin borrar |
docker compose down |
bajarlo (contenedores y red) |
docker compose down -v |
bajarlo con sus volúmenes |
docker compose config |
el archivo tal como lo entiende Compose |
docker compose ls |
los proyectos de la máquina |