1. Riesgos de un solo archivo Compose grande
Cuando empiezas a usar Compose, parece ideal: un solo compose.yaml, un solo comando docker compose up y todo «despega» solo. Los problemas llegan silenciosamente, como las actualizaciones en producción: primero agregas Redis, luego RabbitMQ, luego «solo» un puerto de depuración, y finalmente descubres que en un solo archivo viven dos realidades: el inicio canónico y el modo de desarrollo local. Al final, YAML se vuelve como una clase gigante de 2000 líneas que tiene lógica de negocio, interfaz de usuario, acceso a la base de datos y «un poco de magia».
La parte más desagradable aquí no es ni siquiera el tamaño del archivo, sino la mezcla de responsabilidades. En compose.yaml terminan gradualmente configuraciones que solo necesitas «ahora mismo en tu portátil», y junto a ellas configuraciones que deberían ser estables y comunes para todo el equipo. Como en Java: si mezclas DTO, servicios, repositorios y además Thread.sleep() en un mismo lugar, el código parece funcionar, pero vivir con él se vuelve triste.
El archivo Compose se degrada especialmente rápido cuando empiezas a agregar depuración. El servicio Spring Boot obtiene un segundo puerto, aparecen argumentos JVM para JDWP, a veces quieres activar más logs, a veces cambiar el target de Dockerfile al stage de desarrollo. Y si metes todo esto en el archivo base, el «inicio normal» deja de ser normal. Un nuevo desarrollador arrancará el stack y se preguntará: ¿por qué el puerto de depuración está abierto? ¿por qué el servicio se construye como desarrollo y no como runtime? ¿por qué tenemos un logueo como si investigáramos el crimen del siglo?
Otro efecto es el copiar y pegar. A menudo la gente soluciona el problema así: hacen un segundo archivo, pero… copian casi todo el servicio app completo. Hoy coincide, pero mañana cambias una variable de entorno en un lugar, te olvidas en el otro — y obtienes dos versiones de la realidad: «modo normal» y «modo dev» que se comportan de manera diferente porque YAML se ha bifurcado.
Para evitar esto, necesitamos una regla muy simple: el archivo base debe ser «aburrido, estable y canónico», y el archivo dev debe ser «pequeño y preciso». Y sí, en YAML esto suena casi como un sueño imposible, pero lo intentaremos.
2. El compose.yaml canónico: aburrido y estable
La palabra «canónico» aquí no es para pomposidad, sino para acordar el significado. Un compose.yaml canónico es aquel que describe el modelo normal del stack: qué servicios existen, cómo están conectados, qué volúmenes y healthchecks se consideran estándar, qué puertos se exponen al exterior y qué variables de entorno son necesarias para que el servicio funcione en modo «normal». Este es el archivo que quieres mostrar a un colega diciendo: «así es como funciona nuestro entorno local».
Y «aburrido» aquí es realmente un elogio. Aburrido significa predecible. No intenta adivinar qué estás depurando hoy. No incluye depuración «por si acaso». No se adapta a un IDE específico. Simplemente levanta el stack tal como debe funcionar en el escenario básico. Si en Java un buen application.yml por defecto no debe ser «para casos especiales», entonces compose.yaml tampoco es el lugar para «casos especiales».
En nuestro curso esto es aún más importante metodológicamente: compose.yaml no es solo una forma de ejecución, sino también un documento. Con él, el estudiante (y tú dentro de un mes) reconstruye la imagen: qué dependencias tiene el servicio, cuáles son sus nombres, qué puertos externos se necesitan, dónde está el estado (volumes), cómo está organizado el inicio.
Es muy útil dividir mentalmente las configuraciones en dos tipos: las que reflejan la arquitectura del entorno y las que reflejan las comodidades de desarrollo. Las cosas arquitectónicas deben estar en el archivo base. Las comodidades de desarrollo — sácalas por separado.
Aquí tienes una tabla compacta que ayuda a mantener el límite:
| Categoría de configuración | Ejemplo | Lugar lógico |
|---|---|---|
| Composición del stack | , , , |
compose.yaml |
| Dependencias y readiness | depends_on, healthcheck, redes | compose.yaml |
| Puertos estables | 8080:8080 para API, puertos de UI de gestión | compose.yaml |
| Configuración básica de la aplicación en el stack completo | SPRING_PROFILES_ACTIVE = "postgres,cache,messaging" | compose.yaml |
| Puerto de depuración y JDWP | 5005:5005, JAVA_TOOL_OPTIONS = ...jdwp... | compose.dev.yaml |
| Target de Dockerfile para desarrollo | build.target = development | compose.dev.yaml |
Para este límite, ahora nos basta con un ejemplo compacto de full-stack con un SPRING_PROFILES_ACTIVE fijo. En cuanto no ejecutes todo el stack, este parámetro es mejor definirlo mediante un script de lanzamiento, no incrustarlo como una constante eterna.
Observa: esto no es una «prohibición», es un «punto de propiedad». El archivo base es el dueño del comportamiento canónico.
3. compose.dev.yaml como overlay
compose.dev.yaml en nuestro modelo no es un proyecto alternativo ni un «segundo stack de Compose». Es precisamente un segundo archivo que contiene solo las diferencias respecto al inicio canónico. Su papel es similar a una capa ordenada con @Profile("dev") en Spring: agrega lo necesario para el desarrollo, pero no reescribe todo el mundo desde cero.
La idea principal es muy simple: si abres compose.dev.yaml y ves allí casi una repetición completa de compose.yaml, entonces ya has perdido. No porque «no se pueda», sino porque te has puesto una mina: los archivos inevitablemente divergirán y estarás depurando no código, sino discrepancias de YAML.
En un buen compose.dev.yaml suelen vivir cosas como «cambiar el target de compilación», «agregar un puerto de depuración», «agregar una variable de entorno». Literalmente aplicas una fina capa sobre la configuración base.
Por ejemplo, el archivo base puede ser así (fragmento del servicio app, intencionadamente corto):
# compose.yaml
services:
app:
build:
# Contexto de compilación: Compose buscará el Dockerfile y los fuentes en la raíz del proyecto
context: .
ports:
# Puerto HTTP canónico de la aplicación (debe ser igual para todo el equipo)
- "8080:8080"
environment:
# Para la variante compacta de full-stack fijamos el conjunto completo de perfiles:
# aquí importa el límite entre base y dev, no el cambio condicional
SPRING_PROFILES_ACTIVE: "postgres,cache,messaging"
Y el archivo dev — así:
# compose.dev.yaml
services:
app:
build:
# Capa dev: en desarrollo compilamos no al stage runtime, sino a development
target: development
ports:
# Capa dev: abrimos un puerto adicional para el depurador
- "5005:5005"
Observa el efecto psicológico: el archivo dev no intenta describir de nuevo el context, no repite 8080:8080, no duplica los perfiles. Dice: «en modo dev, app tiene dos particularidades más: un target diferente y un puerto adicional».
Ahora un ejemplo de cómo se ve un «archivo dev malo» (también fragmento):
# compose.dev.yaml (mal)
services:
app:
build:
# Mal: el archivo dev repite el context canónico (es una fuente de verdad extra)
context: .
# La única corrección real dev aquí es target, el resto es mejor dejarlo en la base
target: development
ports:
# Mal: aquí se duplica el puerto canónico, y puede divergir de compose.yaml
- "8080:8080"
# Bien: esto es realmente una adición dev
- "5005:5005"
environment:
# Mal: duplicamos los perfiles canónicos y corremos el riesgo de «olvidar actualizarlos» después
SPRING_PROFILES_ACTIVE: "postgres,cache,messaging"
El problema no es que «así no funcione». Funciona. El problema es que has creado una segunda fuente de verdad. En una semana cambiarás el perfil o el puerto en compose.yaml, te olvidarás en el archivo dev — y tendrás un misterio de nivel «¿por qué en depuración funciona todo bien, pero en normal no?». Sorprendentemente, luego la gente culpa a Spring Boot.
4. La regla del «punto de propiedad» para las sobreescrituras
En cualquier sistema de configuración llega un momento en que lo importante no es «cómo ejecutar», sino «cómo entender por qué se ejecutó exactamente así». Y aquí el «punto de propiedad» es casi un salvavidas. El significado de la regla: cada configuración debe tener un lugar donde viva como fuente principal de verdad. De lo contrario, tendrás una discusión eterna de «¿y dónde está definido esto?».
Para que la regla funcione, no se necesita mucho: dividir las configuraciones en «estables» y «locales» y mantener ese límite. Estables — aquellas que deben funcionar igual para cada estudiante y para un colega del equipo. Locales — aquellas que se activan según la situación y no deben cambiar el inicio canónico.
En el contexto de nuestro proyecto, es útil pensar así: compose.yaml es el «pasaporte» del stack, y compose.dev.yaml son las «pegatinas en el pasaporte». Las pegatinas son útiles, pero el pasaporte no debe volverse ilegible por ellas.
Si quieres una metáfora más ingenieril, puedes imaginar las capas así:
flowchart TD
A["compose.yaml
stack canónico"] --> C["Configuración final
que realmente ejecutará Compose"]
B["compose.dev.yaml
diferencias locales"] --> C
Este diagrama aún no explica los detalles de las reglas de merge (eso es otra conversación importante), pero fija lo principal: compose.dev.yaml no existe por sí mismo. Su sentido es modificar el stack base, no crear un «universo paralelo».
Y sí, aquí ayuda mucho la disciplina de «menos líneas». Si el archivo dev crece, casi siempre es un síntoma de que empezaste a duplicar la base. En la mayoría de los casos, cuando el archivo dev se expande, la reacción correcta no es «bueno, que así sea», sino «¿por qué no podemos mantener esto en la base o, al contrario, por qué esto debería ser parte de dev?».
5. Comandos de inicio honestos
Cuando la configuración se divide en parte base y parte dev, es importante que también el comando de inicio sea igual de honesto. Es decir, que se vea: «estoy iniciando el stack canónico» o «estoy iniciando el stack con overlay dev». Esto parece un detalle, pero luego ahorra horas de explicaciones.
El inicio canónico debe ser el comando más simple posible. Idealmente sin -f, porque el archivo base se llama compose.yaml y Compose lo tomará automáticamente:
# Inicio canónico (sin capas dev)
docker compose up --build
Y el inicio dev debe ser tal que no puedas olvidar que estás en modo dev. Por eso indicamos explícitamente ambos archivos:
# Inicio con overlay dev: el segundo archivo agregará solo las diferencias respecto a la base
docker compose -f compose.yaml -f compose.dev.yaml up --build
Esta explicitud es parte del «developer workflow»: lees el historial del terminal y entiendes por qué de repente tienes abierto el puerto 5005 o por qué la compilación pasa por el stage de development.
Aquí vale la pena comentar brevemente el popular mecanismo «oculto»: compose.override.yaml. Compose realmente puede tomar automáticamente un archivo override con ese nombre. Pero como enfoque pedagógico y como disciplina de equipo, esto a menudo se convierte en magia: una persona ejecuta docker compose up y ni siquiera es consciente de que se han aplicado algunas sobreescrituras.
En nuestro curso preferimos deliberadamente compose.dev.yaml y el explícito -f ... -f ..., porque es mejor para la comprensión y la reproducibilidad. Cuando el comando muestra explícitamente el archivo dev, hay menos «misterio» y más control ingenieril.
6. Aplicación en Container-Ready Catalog Service
Ahora aterricemos en nuestro repositorio de aprendizaje. Ya tenemos en la raíz Dockerfile, compose.yaml, y según el plan de la plantilla final debería aparecer compose.dev.yaml. En una buena versión, la estructura es muy reconocible:
docker-java-catalog-service/
├── Dockerfile
├── compose.yaml
├── compose.dev.yaml
└── .env.example
El compose.yaml base guarda «qué es nuestro stack». Para la lección no necesitamos todo el archivo de 200 líneas, basta con mostrar el principio clave en app. Por ejemplo, el servicio base puede fijar el modo runtime full-stack habitual y el puerto HTTP:
# compose.yaml (fragmento)
services:
app:
build:
# Context canónico: esto es parte del «pasaporte» del stack
context: .
ports:
# Puerto HTTP canónico de la aplicación
- "8080:8080"
environment:
# Para el ejemplo compacto de full-stack fijamos el conjunto completo de perfiles:
# aquí importa el límite entre la capa base y la capa dev
SPRING_PROFILES_ACTIVE: "postgres,cache,messaging"
Y compose.dev.yaml agrega cuidadosamente las diferencias de desarrollo. Por ejemplo, el target de desarrollo y el puerto de depuración (sin intentar reescribir la base):
# compose.dev.yaml (fragmento)
services:
app:
build:
# Modo dev: solo cambiamos el target de compilación
target: development
ports:
# Modo dev: solo agregamos el puerto de depuración
- "5005:5005"
Es importante que ahora no estamos discutiendo «cómo configurar exactamente la depuración» o «qué parámetros JVM exactos se necesitan». Eso es realmente un tema aparte, y tiene sentido solo después de que hayas aprendido a separar limpiamente el modo dev del modo normal. Pero incluso sin la línea JDWP ya se ve lo principal: el archivo dev es un overlay fino que no destruye el modelo canónico.
Y otro punto práctico que parece gracioso hasta que te topas con él: el compose.yaml base debe seguir siendo autónomo. Es decir, el comando docker compose up --build debe funcionar sin el archivo dev obligatorio. De lo contrario, conviertes el modo de desarrollo en la «norma» y el modo canónico en algo exótico que nadie ejecuta. Es como tener @Profile("prod") desactivado por defecto y sorprenderse de que en producción todo sea diferente.
7. Errores típicos
Cuando dividimos por primera vez Compose en dos archivos, el cerebro a menudo intenta «hacerlo más simple», y de ahí surgen errores similares. Son normales porque YAML por sí mismo provoca copiar y pegar (así es), pero es mejor reconocerlos por sus síntomas que por pesadillas nocturnas.
Error nº1: el archivo dev se convierte en una copia del base.
Normalmente empieza con la buena intención de «solo duplico app y luego agrego un par de líneas». En una semana cambias una variable en el archivo base y el archivo dev se «queda atrás». Como resultado, los modos normal y dev comienzan a comportarse de manera diferente no porque esté diseñado así, sino porque las configuraciones divergieron. Se cura con el principio estricto: en el archivo dev dejamos solo las diferencias, todo lo demás en la base.
Error nº2: las configuraciones de depuración se colocan en compose.yaml porque «me resulta cómodo».
Cómodo sí, pero eso convierte el modo dev en el predeterminado. Luego llega un colega, inicia el stack y obtiene un puerto abierto 5005 y una JVM escuchando al depurador, aunque él no lo necesita en absoluto. El archivo base debe describir el inicio normal; la depuración es una capa local y debe ser explícitamente activable.
Error nº3: se crea un segundo servicio app-debug en lugar de sobrescribir app.
A primera vista parece lógico: «que haya app y app-debug». En la práctica duplicas el cableado: puertos, env vars, depends_on, volumes. Y luego comienza la carrera: ¿qué servicio es el «real»? ¿dónde cambiar la configuración? ¿por qué uno funciona y el otro no? En nuestro modelo educativo es mucho más simple tener un solo servicio app y dos modos de inicio mediante overlay.
Error nº4: el archivo base deja de funcionar sin el archivo dev.
A veces terminan en el archivo dev cosas que en realidad son parte del stack «canónico», como variables de entorno obligatorias o puertos necesarios. Entonces compose.yaml se convierte en un «borrador no documentado» y la realidad solo vive en modo dev. Esto es peligroso porque rompe la reproducibilidad: el «inicio normal» deja de ser normal.
Error nº5: en el archivo dev empiezan a meterse rutas absolutas dependientes del SO.
Esto es especialmente común en Windows/macOS. Alguien escribe C:\Users\... o /Users/name/... directamente en YAML, y todo «funciona en el autor». Luego se commitea el archivo — y el entorno de los demás no se levanta. Aunque el archivo dev contenga diferencias locales, sigue siendo parte del workflow del equipo, por lo que las rutas relativas y la multiplataforma son importantes. Si hay una configuración realmente local que no debe vivir en el repositorio, es mejor pensar en un archivo local fuera de VCS (pero como enfoque del curso mantenemos compose.dev.yaml como un archivo de equipo y reproducible).
GO TO FULL VERSION