Docker Compose: guía práctica para desarrolladores

Por 2026-09-29
DockerDocker ComposeContenedoresSpring BootPostgreSQLDevOpsBackend
Puerto de contenedores con grúas y barcos bajo un cielo despejado — Docker Compose, guía práctica
Foto de Nezaket en Pexels

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 compose y docker-compose no 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.yml sigue funcionando por compatibilidad, pero la documentación oficial prefiere el primero, y si existen los dos, gana compose.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-offline antes de copiar el código. Docker cachea cada capa. Copiando primero solo el pom.xml y descargando dependencias, esa capa se reutiliza mientras no cambies el pom.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 db no tiene ports:, 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ñade ports: ["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 healthcheck en el servicio de base de datos. pg_isready es la herramienta de PostgreSQL para eso: devuelve éxito cuando el servidor acepta conexiones. Compose la ejecuta cada 5 segundos y marca el contenedor como healthy cuando responde.
  • condition: service_healthy en el depends_on de 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:

ComandoQué hace
docker compose up -dLevanta todos los servicios en segundo plano
docker compose up -d --buildIgual, reconstruyendo las imágenes con build:
docker compose psEstado de cada servicio, incluido el de salud
docker compose logs -f appSigue los logs de un servicio
docker compose exec db psql -U pedidosAbre una sesión dentro de un contenedor en marcha
docker compose configValida el fichero y muestra la configuración final
docker compose downPara y borra contenedores y red; conserva volúmenes
docker compose down -vLo 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 up y todo el equipo tiene lo mismo.
  • compose.yaml, docker compose con espacio y sin version:: si ves lo contrario, el ejemplo es antiguo.
  • depends_on solo 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.
  • down conserva los datos, down -v los 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/amd64 con buildx, 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.

¿Listo para transformar tu stack tecnológico?

Hablemos sobre cómo llevar tus sistemas al siguiente nivel, optimizar el rendimiento y potenciar el talento de tu equipo.

O cuéntame por escrito

Protegido por reCAPTCHA. Tus datos solo se usan para responderte — ver la política de privacidad.