Saltar al contenido
sudoquest@root
Docker

Compose

Varios contenedores como un proyecto: el compose.yaml, up y down, los servicios por su nombre, volúmenes, depends_on con healthcheck y el .env.

30 min de lectura

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