Docker Compose: guía práctica para desarrolladores

Tu aplicación no vive sola: necesita una base de datos, a veces una caché, una cola o un servicio de correo falso para pruebas. Docker Compose es la forma de levantar todo eso con un comando, igual en el portátil de cualquiera del equipo. Esta guía lo monta de verdad con Spring Boot y PostgreSQL, incluidos los detalles que los tutoriales se saltan.
Docker Compose resuelve un problema muy concreto. Arrancar un contenedor con docker run es fácil. Arrancar cuatro que tienen que verse entre sí, en el orden correcto, con sus variables, sus puertos y sus volúmenes, a base de comandos sueltos, no lo es. Y todavía menos conseguir que el compañero que se incorpora mañana lo haga igual.
Compose convierte todo eso en un fichero que vive en el repositorio. Quien clona el proyecto ejecuta docker compose up y tiene el entorno completo. Ese fichero es, además, la documentación más fiable de qué necesita tu aplicación para funcionar: si falta algo, no arranca.
Todo lo que sigue está ejecutado con Docker 29.2, Compose 5.5, Java 25, Spring Boot 4.1 y PostgreSQL 18, en un Mac con Apple Silicon. Los resultados que se muestran son los reales.
Qué es Docker Compose (y qué no es)
Docker Compose es una herramienta para definir y ejecutar aplicaciones de varios contenedores a partir de un fichero YAML. Cada pieza de tu aplicación es un servicio; Compose crea una red privada donde los servicios se encuentran por su nombre, arranca los contenedores y los gestiona como un conjunto.
Tres aclaraciones que evitan confusiones frecuentes:
docker composeydocker-composeno son lo mismo. El comando con guion era Compose v1, escrito en Python. El actual, la v2 y posteriores, está escrito en Go y se invoca como plugin de Docker, con espacio:docker compose. Un tutorial que usa el guion suele ser de la época de v1.- La clave
version:al principio del fichero sobra. Compose v2 la ignora, y si la pones avisa:the attribute 'version' is obsolete, it will be ignored. Es otro indicio de un ejemplo copiado de hace años. - El nombre recomendado del fichero es
compose.yaml.docker-compose.ymlsigue funcionando por compatibilidad, pero la documentación oficial prefiere el primero, y si existen los dos, ganacompose.yaml.
Lo que Compose no es: un orquestador de producción. No reparte contenedores entre varias máquinas ni los recoloca si un servidor cae. Para eso está Kubernetes, y lo trato al final.
El ejemplo: Spring Boot + PostgreSQL
La aplicación es un servicio de pedidos mínimo con Spring Boot 4.1 y Java 25: una entidad Pedido, un repositorio JPA y un controlador con dos endpoints, GET /pedidos y POST /pedidos. Lo importante no es el código Java, sino cómo se empaqueta y se conecta.
El Dockerfile: dos etapas
# Etapa 1: compilar con el JDK completo
FROM eclipse-temurin:25-jdk AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -q dependency:go-offline
COPY src ./src
RUN ./mvnw -q package -DskipTests
# Etapa 2: ejecutar solo con el JRE
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Dos decisiones que merecen explicación:
- Compilación multietapa. La primera etapa tiene el JDK, Maven y todas las dependencias; la segunda solo el JRE y el
.jar. La imagen final no arrastra el compilador ni la caché de Maven, y es más pequeña y con menos superficie de ataque. dependency:go-offlineantes de copiar el código. Docker cachea cada capa. Copiando primero solo elpom.xmly descargando dependencias, esa capa se reutiliza mientras no cambies elpom.xml. Cambiar una línea de Java ya no vuelve a descargar medio Maven Central.
Añade un .dockerignore con target/ para que el contexto de compilación no incluya los artefactos locales.
El fichero compose.yaml
services:
db:
image: postgres:18
environment:
POSTGRES_DB: pedidos
POSTGRES_USER: pedidos
POSTGRES_PASSWORD: ${DB_PASSWORD:-secreto}
volumes:
- datos-db:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pedidos -d pedidos"]
interval: 5s
timeout: 3s
retries: 10
app:
build: .
ports:
- "18080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/pedidos
SPRING_DATASOURCE_USERNAME: pedidos
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-secreto}
depends_on:
db:
condition: service_healthy
adminer:
image: adminer
profiles: ["herramientas"]
ports:
- "18081:8080"
volumes:
datos-db:
Cada bloque tiene un porqué, y las siguientes secciones lo desmontan pieza a pieza. Antes, lo que pasa al ejecutarlo:
docker compose up -d --build
Container pedidos-db-1 Started
Container pedidos-db-1 Waiting
Container pedidos-db-1 Healthy
Container pedidos-app-1 Starting
Container pedidos-app-1 Started
Fíjate en el orden: Compose arranca la base de datos, espera a que esté sana y solo entonces arranca la aplicación. Unos segundos después:
curl -s localhost:18080/actuator/health
# {"groups":["liveness","readiness"],"status":"UP"}
curl -s -X POST localhost:18080/pedidos \
-H 'Content-Type: application/json' -d '{"producto":"teclado"}'
# {"producto":"teclado","id":1}
Los servicios se encuentran por su nombre
La URL de conexión es jdbc:postgresql://db:5432/pedidos. No hay ninguna IP: db es el nombre del servicio, y Compose lo resuelve dentro de la red que crea para el proyecto.
De aquí salen dos consecuencias prácticas:
- La base de datos no publica ningún puerto. El servicio
dbno tieneports:, así que desde tu máquina no se puede acceder a ella, pero la aplicación sí, por la red interna. Es la configuración más segura por defecto. Si quieres conectar tu cliente SQL de escritorio, añadeports: ["5432:5432"], sabiendo lo que abres. - Dentro de la red se usa el puerto del contenedor, no el publicado. La aplicación escucha en el 8080 dentro del contenedor y se publica en el 18080 del host. Otro servicio que la llamara usaría
http://app:8080, no el 18080.
Spring Boot hace el resto: las variables SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME y SPRING_DATASOURCE_PASSWORD sobrescriben la configuración por su nombre, sin tocar application.properties. La misma imagen sirve para cualquier entorno cambiando solo el entorno.
depends_on no espera a que el servicio esté listo (salvo que se lo digas)
Este es el fallo más común en ficheros Compose, y la documentación oficial lo dice sin rodeos: al arrancar, Compose no espera a que un contenedor esté «listo», solo a que esté en ejecución.
Con un depends_on: [db] a secas, Compose arranca PostgreSQL y acto seguido la aplicación. PostgreSQL tarda unos segundos en aceptar conexiones, Spring Boot intenta conectar en el arranque, falla y el contenedor muere. Muchos equipos lo «arreglan» con reintentos o con un sleep en el entrypoint. Ninguna de las dos cosas hace falta.
La solución correcta son dos piezas juntas:
- Un
healthchecken el servicio de base de datos.pg_isreadyes la herramienta de PostgreSQL para eso: devuelve éxito cuando el servidor acepta conexiones. Compose la ejecuta cada 5 segundos y marca el contenedor comohealthycuando responde. condition: service_healthyen eldepends_onde la aplicación. Ahora Compose espera a que la base de datos esté sana antes de arrancar la aplicación, que es exactamente lo que se ve en la salida de antes (Waiting→Healthy→Starting).
Hay una tercera condición útil, service_completed_successfully, para servicios que deben terminar antes de que arranque otro: una migración de esquema o una carga de datos inicial.
Volúmenes: dónde viven los datos (y el cambio de PostgreSQL 18)
Un contenedor es desechable: si lo borras, se borra todo lo que escribió dentro. Los datos de la base de datos tienen que vivir fuera, en un volumen. En el ejemplo es un volumen con nombre, datos-db, declarado al final del fichero.
El comportamiento, comprobado:
docker compose down # borra contenedores y red
docker compose up -d
curl -s localhost:18080/pedidos
# [{"producto":"teclado","id":1}] ← el pedido sigue ahí
docker compose down -v # además borra los volúmenes
docker compose up -d
curl -s localhost:18080/pedidos
# [] ← base de datos vacía
La diferencia entre down y down -v es la que decide si pierdes los datos de desarrollo. down -v es justo lo que quieres para empezar de cero, y justo lo que no quieres ejecutar por inercia.
Ojo con la ruta del volumen en PostgreSQL 18. Casi todos los ejemplos que encontrarás montan el volumen en /var/lib/postgresql/data, que era la ruta de datos hasta PostgreSQL 17. En la imagen oficial de la versión 18 los datos están en /var/lib/postgresql/18/docker, y el volumen se monta en /var/lib/postgresql. Compruébalo tú mismo:
docker compose exec db printenv PGDATA
# /var/lib/postgresql/18/docker
Si copias un compose.yaml antiguo y cambias postgres:17 por postgres:18 sin tocar el volumen, no estás montándolo donde la imagen espera los datos. Revisa la documentación de la imagen al subir de versión mayor.
Variables y secretos de desarrollo
La contraseña aparece como ${DB_PASSWORD:-secreto}: Compose sustituye la variable DB_PASSWORD si existe y, si no, usa secreto. El valor puede venir del entorno o de un fichero .env junto al compose.yaml, que Compose lee automáticamente.
Para ver qué configuración resulta de verdad, después de sustituir variables, está docker compose config:
DB_PASSWORD=otra docker compose config | grep PASSWORD
# SPRING_DATASOURCE_PASSWORD: otra
# POSTGRES_PASSWORD: otra
docker compose config es también la mejor forma de validar el fichero antes de arrancar nada: si hay un error de sintaxis o de indentación, lo dice ahí.
Una advertencia: esto está bien para desarrollo. Una contraseña por defecto en el fichero es aceptable para una base de datos local que se borra con down -v; no lo es para nada que salga de tu máquina. En entornos compartidos, los secretos van en el gestor de secretos de la plataforma, no en el repositorio.
Perfiles: servicios que solo arrancan cuando los pides
El servicio adminer (un cliente web de bases de datos) lleva profiles: ["herramientas"]. Eso significa que no arranca con un up normal:
docker compose up -d # arranca app y db
docker compose --profile herramientas up -d # arranca también adminer
Los perfiles resuelven un problema habitual del compose.yaml que crece: herramientas de depuración, un servidor de correo falso, un generador de carga… Útiles de vez en cuando y un estorbo el resto del tiempo. Con perfiles, el fichero es uno solo y cada persona levanta lo que necesita.
Construir para amd64 desde un Mac con Apple Silicon
Este problema no aparece en ningún tutorial básico y aparece siempre en el primer despliegue. Un Mac con Apple Silicon es arm64. Casi todos los servidores y clústeres en la nube son amd64 (x86_64). docker build construye por defecto para la arquitectura de tu máquina, así que la imagen que funciona perfectamente en tu portátil falla en el servidor con un escueto exec format error.
La solución es construir para la plataforma de destino con buildx. Y hay un truco que ahorra mucho tiempo en proyectos Java: compilar en nativo y empaquetar para la otra arquitectura. El .jar es independiente de la arquitectura, así que no hace falta emular Maven entero. Basta con fijar la plataforma de la etapa de compilación a la de tu máquina:
FROM --platform=$BUILDPLATFORM eclipse-temurin:25-jdk AS build
$BUILDPLATFORM es la arquitectura de la máquina que construye. La etapa final sigue tomando la plataforma de destino:
docker buildx build --platform linux/amd64 -t pedidos:amd64 --load .
docker image inspect pedidos:amd64 --format '{{.Os}}/{{.Architecture}}'
# linux/amd64
Maven corre a velocidad nativa y solo la imagen del JRE es amd64. Si necesitas publicar para las dos arquitecturas, --platform linux/amd64,linux/arm64 junto con --push a tu registro genera una imagen multiarquitectura que cada máquina descarga en su variante.
Los comandos del día a día
Con el fichero en su sitio, el trabajo diario se reduce a unos pocos comandos:
| Comando | Qué hace |
|---|---|
docker compose up -d | Levanta todos los servicios en segundo plano |
docker compose up -d --build | Igual, reconstruyendo las imágenes con build: |
docker compose ps | Estado de cada servicio, incluido el de salud |
docker compose logs -f app | Sigue los logs de un servicio |
docker compose exec db psql -U pedidos | Abre una sesión dentro de un contenedor en marcha |
docker compose config | Valida el fichero y muestra la configuración final |
docker compose down | Para y borra contenedores y red; conserva volúmenes |
docker compose down -v | Lo mismo, borrando también los volúmenes |
Un detalle: Compose nombra los contenedores y los volúmenes con el nombre del proyecto delante, que por defecto es el del directorio. Si dos proyectos distintos tienen un servicio db, no chocan. Con -p <nombre> fijas el nombre del proyecto.
De Compose a Kubernetes
Compose es excelente para desarrollo, pruebas de integración y pipelines de CI. En producción, a partir de cierto tamaño, aparecen necesidades que no cubre: varias máquinas, reemplazo automático de contenedores caídos, despliegues sin corte, autoescalado. Ese es el terreno de Kubernetes.
La buena noticia es que lo que has hecho aquí no se tira. Las imágenes son las mismas, las variables de entorno siguen siendo el mecanismo de configuración, el healthcheck de Compose tiene su equivalente en las sondas de Kubernetes, y la idea de servicios que se encuentran por su nombre es exactamente cómo funcionan los Service de Kubernetes. Si te estás planteando ese salto, en cómo elegir una consultoría de Kubernetes repaso qué conviene tener claro antes.
Si trabajas con Spring Boot
Spring Boot se lleva especialmente bien con este modelo: configuración por variables de entorno, actuator para la salud y un único .jar ejecutable. Si vienes de Spring Boot 3, los cambios de la versión 4 están en Spring Boot 4: novedades y migración desde Spring Boot 3, y la versión de Java que conviene usar, en Java 25: novedades, LTS y cuándo dejar Java 21.
El ciclo completo de una aplicación Spring Boot en producción —Docker, Kubernetes y pipelines de CI/CD, junto con arquitectura hexagonal, mensajería y microservicios— es lo que cubre Spring Boot Avanzado.
En resumen
- Compose describe tu entorno en un fichero que vive en el repositorio:
docker compose upy todo el equipo tiene lo mismo. compose.yaml,docker composecon espacio y sinversion:: si ves lo contrario, el ejemplo es antiguo.depends_onsolo espera a que el servicio arranque. Para esperar a que esté listo:healthcheck+condition: service_healthy.- Los servicios se encuentran por nombre, y la base de datos no necesita publicar puertos.
downconserva los datos,down -vlos borra. Y en PostgreSQL 18 el volumen va en/var/lib/postgresql.- Perfiles para las herramientas que no siempre necesitas.
- Desde un Mac, construye para
linux/amd64conbuildx, compilando en nativo con--platform=$BUILDPLATFORM.
¿Buscas un consultor IT para tu empresa? Conoce mis servicios de consultoría IT: arquitectura cloud, desarrollo fullstack y liderazgo técnico. ¿Empresa en Mallorca o Baleares? Consultor IT en Mallorca.