1. Plantilla: un conjunto coherente de reglas
Cuando se habla de «plantilla final», los principiantes a menudo imaginan un mágico Dockerfile de 200 líneas que «venció a los contenedores para siempre». En realidad, una buena plantilla es más modesta y útil: un conjunto de decisiones que no se contradicen entre sí. Si quieres simultáneamente non-root, pero escribes en un directorio sin permisos, no tienes una plantilla, sino un problema. Si quieres same image, different config, pero construyes cada perfil por separado, eso tampoco es una plantilla, sino una colección de universos alternativos.
Para no ahogarse en los detalles, es útil tener en mente un esquema simple: empaquetamos la aplicación en una imagen, luego la ejecutamos en diferentes modos, y Compose es el «clip» que ensambla el entorno local alrededor del servicio.
flowchart TD
A[Gradle proyecto] --> B[Dockerfile: builder stage]
B --> C[Dockerfile: runtime stage]
C --> D[(Docker image)]
D --> E[Standalone container]
D --> F[Compose: app + postgres + redis + rabbitmq]
F --> G[Runtime config: env vars / profiles / mounts]
De eso se trata la idea: la plantilla debe ser corta, inequívoca y repetible. No tiene que ser la más pequeña de internet. Tiene que ser tal que dentro de un mes puedas leerla y no preguntarle al espejo: «¿Quién escribió esto y por qué me odia?».
2. Conjunto de archivos de la plantilla en la raíz del repositorio
La plantilla final no solo es Dockerfile. Si dejas solo un Dockerfile pero no fijas .dockerignore, Compose y los comandos de ejecución, en la práctica todo volverá a caer en «bueno, en mi máquina funciona». Por eso, mentalmente armamos un «esqueleto del repositorio»: unos pocos archivos raíz que forman el contrato de ejecución. Son pocos, y es a propósito, porque una plantilla que crece hasta diez Dockerfiles y siete archivos compose deja de ser educativa y portable.
Así es como se ve normalmente el «mínimo saludable» (lo muestro como guía, no como dogma):
docker-java-catalog-service/
├── Dockerfile
├── .dockerignore
├── compose.yaml
├── compose.dev.yaml
├── .env.example
├── README.md
├── scripts/
│ └── ...
└── src/
└── ...
El significado de este conjunto es simple: Dockerfile se encarga de cómo empaquetar, compose.yaml de cómo levantar el entorno, compose.dev.yaml de cómo habilitar dev/debug sin ensuciar la base, y .dockerignore y .env.example lo protegen de accidentes y secretos. Sí, es aburrido. Por eso funciona.
.env.example aquí no es decorativo: enumera al menos SPRING_DATASOURCE_PASSWORD y SPRING_RABBITMQ_PASSWORD. Si los secretos son más cómodos de proporcionar mediante un archivo, en el repositorio solo se deja el archivo de ejemplo, y el valor real vive fuera y no entra ni en git ni en el build context.
3. Dockerfile final: «pocas líneas, mucho significado»
Cuando llevas el Dockerfile al estado de plantilla, quieres leerlo como una historia corta. Primero construimos el artefacto (etapa builder), luego lo ejecutamos en un entorno runtime limpio (etapa runtime), ejecutándolo como non-root y con rutas escribibles preparadas. Y muy importante: el Dockerfile no debe conocer tus contraseñas, «las rutas locales de Vasya» ni «tengo el puerto 8081 porque el 8080 está ocupado».
Para que el Dockerfile no se descontrole, fijamos una base manual: builder → runtime, una línea de imagen probada eclipse-temurin:25-jdk-jammy / eclipse-temurin:25-jre-jammy, un jar /app/application.jar, y la construcción manual de la imagen base solo se considera en forma de docker build --target runtime -t catalog-service:local .. Si en el mismo Dockerfile viven development o targets en capas, siguen siendo opcionales y se construyen solo con --target explícito.
Builder stage: Gradle solo para compilación
Builder stage es el lugar donde tienen cabida las fuentes y la herramienta de compilación. La imagen final no debe saber qué es gradlew, de lo contrario comienza a parecer un «contenedor con tu tarea» y no un runtime para el servicio.
Pequeño fragmento del builder stage (Java 25):
FROM eclipse-temurin:25-jdk-jammy AS builder
WORKDIR /build
# Copiamos el wrapper y los scripts de compilación por separado — esto ayuda a la caché de Docker
COPY gradlew .
COPY gradle gradle
COPY build.gradle.kts settings.gradle.kts ./
# Los fuentes son la capa que cambia más a menudo, por eso los copiamos después
COPY src src
# Compilamos el jar sin el daemon de Gradle dentro del contenedor
RUN ./gradlew bootJar --no-daemon
Observa el orden. Primero copiamos las cosas «estables» (wrapper, gradle/, scripts de compilación), y solo luego src/. Así la caché de Docker ayuda y no se ríe de ti. Sí, es «ese nivel sobre la caché», solo que ahora no es teoría, sino parte del estándar final.
Runtime stage: non-root, ruta escribible y healthcheck en un solo orden
Runtime stage es el lugar donde debe haber un mínimo de cosas superfluas. Aquí instalamos la herramienta mínima para healthcheck, creamos un usuario, preparamos directorios, copiamos el jar y cambiamos a non-root. Es importante no confundirse: todo lo que requiere permisos de root va antes de USER appuser.
FROM eclipse-temurin:25-jre-jammy AS runtime
ARG UID=10001
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
RUN adduser --disabled-password --gecos "" \
--home "/nonexistent" --shell "/sbin/nologin" \
--no-create-home --uid "${UID}" appuser \
&& mkdir -p /app/data/exports \
&& chown -R appuser:appuser /app
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /build/build/libs/*.jar /app/application.jar
USER appuser
ENTRYPOINT ["java", "-jar", "/app/application.jar"]
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s \
CMD curl -fsS http://localhost:8080/actuator/health | grep '"status":"UP"' || exit 1
El orden aquí es importante: primero instalamos la herramienta mínima para healthcheck, luego creamos el usuario y la ruta escribible, luego copiamos el jar, y solo después cambiamos a non-root. Si se cambia el orden, el Dockerfile empezará a fallar rápidamente por permisos, y la plantilla volverá a depender del azar.
Sí, curl aumenta ligeramente el runtime stage, pero aquí es una compensación consciente: la plantilla local obtiene un healthcheck honesto a nivel de Docker. Si el servicio escucha en otro puerto, el healthcheck debe cambiarse junto con él, no vivir su propia vida.
Layered target mediante jarmode=tools
Layered path sigue siendo una optimización opcional. Es útil cuando quieres conscientemente acelerar el rebuild/pull, pero no reemplaza la base manual runtime.
El punto clave es la extracción de capas mediante jarmode=tools:
FROM runtime AS extractor
RUN java -Djarmode=tools -jar /app/application.jar extract \
--layers --destination /app/extracted
El propio runtime-layered se construye a partir de las capas extraídas y se ejecuta como un target separado. Debe construirse explícitamente (docker build --target runtime-layered ...), de lo contrario el Dockerfile volverá a parecer un conjunto de opciones equivalentes en lugar de una base clara.
bootBuildImage: ruta de compilación alternativa
Buildpacks y bootBuildImage son un buen camino gestionado, pero aquí es una alternativa adulta vecina, no un nuevo default de la plantilla. La base manual sigue siendo builder → runtime; buildpacks simplemente ofrecen una segunda forma de obtener una imagen sin describir manualmente cada paso del empaquetado.
La «señal» mínima que normalmente vive en el README de la plantilla:
./gradlew bootBuildImage
docker image ls | grep catalog
En una buena plantilla ambos caminos coexisten: Dockerfile como una «base ingenieril» máximamente transparente, buildpacks como una «base gestionada» rápida. Lo principal es no intentar mezclarlos en un lío donde parte del equipo construye una cosa, otra parte otra, y luego pasas medio día averiguando por qué en casa de Pedro es así y en casa de María es diferente.
4. compose.yaml: entorno local básico
El archivo Compose en la plantilla final es el «pasaporte del entorno local». Debe ser lo suficientemente completo para levantar un entorno de trabajo, y lo suficientemente claro para que una persona sin background en DevOps pueda leerlo. Idealmente, compose.yaml levanta tu stack real (app + postgres + redis + rabbitmq), porque es allí donde surgen los verdaderos problemas: readiness, nombres de servicios, volúmenes, puertos, configuración.
Todos los valores sensibles a continuación llegan en tiempo de ejecución: desde el .env local, listado en .env.example, o a través de un archivo de configuración externo con secretos. En compose.yaml solo quedan referencias a ellos.
PostgreSQL: volumen + healthcheck = menos caos
El fragmento que casi siempre se ve similar:
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: catalog
POSTGRES_USER: postgres
# La base y la aplicación reciben el mismo valor desde el .env local
POSTGRES_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?set it in .env}
volumes:
- catalog_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d catalog"]
interval: 5s
timeout: 3s
retries: 10
Aquí lo importante no es «qué escribir exactamente», sino qué expresas con ello. El volumen fija el estado de la BD entre reinicios, el healthcheck da una señal honesta de preparación, y el nombre del servicio postgres se convierte en ese «host» por el cual la aplicación se conecta a la BD dentro de Compose.
Redis y RabbitMQ: las mismas dependencias, solo otros síntomas
Redis suele ser más simple (especialmente en un escenario de caché). Fragmento mínimo:
services:
redis:
image: redis:8.6.0
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
RabbitMQ en el curso es una dependencia, no un universo aparte. Por lo tanto, la base son puertos claros + healthcheck:
services:
rabbitmq:
image: rabbitmq:4.2-management
environment:
RABBITMQ_DEFAULT_USER: catalog
RABBITMQ_DEFAULT_PASS: ${SPRING_RABBITMQ_PASSWORD:?set it in .env}
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 5s
timeout: 5s
retries: 20
App service: una imagen, diferentes modos mediante variables de entorno
El lugar más importante en compose.yaml es app. Aquí comprobamos que la regla same image, different runtime config realmente funciona: no reconstruimos la imagen para postgres, cache, messaging, simplemente configuramos el entorno.
Fragmento corto donde se ve lo principal: perfiles, nombres de servicio y directorio de exportación.
services:
app:
build:
context: .
target: runtime # Construimos explícitamente el target runtime canónico
environment:
# Una misma imagen, diferentes modos — solo mediante variables de entorno
SPRING_PROFILES_ACTIVE: postgres,cache,messaging
# Dentro de Compose, en lugar de localhost usamos el nombre del servicio
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/catalog
SPRING_DATASOURCE_USERNAME: postgres
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?set it in .env}
SPRING_DATA_REDIS_HOST: redis
SPRING_RABBITMQ_HOST: rabbitmq
SPRING_RABBITMQ_USERNAME: catalog
SPRING_RABBITMQ_PASSWORD: ${SPRING_RABBITMQ_PASSWORD:?set it in .env}
# Ruta escribible dentro del contenedor (luego la montaremos en el host)
APP_EXPORT_DIR: /app/data/exports
Y el fragmento que conecta la ruta escribible dentro del contenedor con la carpeta del host:
services:
app:
volumes:
- ./data/exports:/app/data/exports
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
En este fragmento hay muchas reglas «adultas» del curso de una vez. Usamos nombres de servicio en lugar de localhost. No adivinamos readiness mediante sleep. No escribimos dentro del contenedor «en cualquier lado», sino que sacamos la exportación al exterior. Y no escondemos lo importante en «magia»: todo se ve en YAML.
Si prefieres un camino con archivo de secretos externo, solo cambia la forma de proporcionar SPRING_DATASOURCE_PASSWORD y SPRING_RABBITMQ_PASSWORD; el propio compose.yaml sigue sin almacenar credenciales literales.
5. compose.dev.yaml: dev/debug como override
El error más frecuente de una plantilla «casi final» es cuando el puerto de debug, el target dev y las variables de entorno experimentales viven en el compose.yaml base. Como resultado, ejecutas un «entorno normal» y de repente escucha debug en el 5005, escribe logs en TRACE y se comporta como una rata de laboratorio. Para que esto no ocurra, compose.dev.yaml debe ser estrecho: solo overrides, solo para desarrollo.
Si necesitas un target development separado para desarrollo local, existe solo como capa de override. El compose.yaml base y el manual build normal siguen viviendo en runtime.
Por ejemplo, cambiamos build.target a development y abrimos el puerto de debug:
services:
app:
build:
target: development
ports:
- "5005:5005"
Y si necesitas habilitar remote debug a través de una variable de entorno, lo hacemos aquí mismo, no en el archivo base:
services:
app:
environment:
JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
El sentido está en la disciplina. compose.yaml debe ser «como en producción, pero local», y compose.dev.yaml «como sea cómodo para el desarrollador, pero bajo control». Entonces no enseñas al equipo dos formas diferentes de vivir. Enseñas una, y proporcionas un modo dev ordenado encima.
6. Mini checklist de vitalidad de la plantilla
La plantilla final no vale por el mero hecho de que los archivos existan, sino porque permiten verificar rápidamente la viabilidad del entorno. Aquí es conveniente pensar no en una lista de «qué hacer», sino en una lista de señales que quieres ver. A continuación, un ejemplo de cómo estas señales suelen fijarse en el README (y cómo tú mismo verificas el proyecto sin adivinación).
| Qué verificas | Comando/acción | Qué se considera «normal» |
|---|---|---|
| Dockerfile al menos es válido | docker buildx build --check --target runtime . | Sin errores de sintaxis ni problemas evidentes de compilación |
| La imagen se construye | docker build --target runtime -t catalog-service:local . | La compilación pasa sin dependencias «accidentales» del host |
| El proceso no es root | + |
UID no es 0, ves appuser (o tu usuario) |
| El servicio responde HTTP | petición a /actuator/health | {"status":"UP"} y logs de inicio adecuados |
| Compose levanta el stack | docker compose up --build | PostgreSQL/Redis/RabbitMQ se vuelven healthy, app arranca después de ellos |
| La exportación escribe donde debe | llamada a /api/catalog/exports y verificación de la carpeta host | El archivo aparece en ./data/exports y no desaparece tras restart |
| Debug no se ha «filtrado» a la base | ejecución sin archivo dev | Sin puerto 5005, sin agente de depuración, comportamiento similar a producción |
Y aquí aparece la principal ventaja de la plantilla: cuando tienes estas señales, sabes distinguir entre «se rompió el código» y «se rompió el entorno». Y eso, sinceramente, es la mitad del desarrollo adulto.
7. Errores típicos al construir la plantilla final
Error n.º 1: «Plantilla final» que aún se ejecuta como root.
A veces parece: bueno, ya hicimos todo, simplemente mantengamos root para no lidiar con permisos. El problema es que no es un detalle menor. Root oculta errores reales del modelo de archivos y crea una falsa sensación de seguridad. En otro entorno, con otros montajes, todo esto sale a la luz y empiezas de nuevo a «arreglar Docker con magia».
Error n.º 2: se habilitó non-root, pero se olvidó preparar la ruta escribible.
Es un clásico del género: USER appuser existe, pero mkdir/chown no. Como resultado, la exportación falla con AccessDeniedException, y la persona empieza a sospechar de Spring, JPA, Mercurio retrógrado — de cualquiera excepto los permisos del directorio. Si el proceso no puede escribir en /app/data/exports, no es que «casi funcione». No funciona.
Error n.º 3: APP_EXPORT_DIR apunta a un lado, y el mount a otro.
Es un error muy «humano»: en YAML escribieron /app/exports, en la configuración /app/data/exports, y luego nos sorprendemos de que los archivos «desaparecieron». En los contenedores, la ruta es un contrato. Si el contrato se viola, Docker no está obligado a adivinar lo que querías decir.
Error n.º 4: Dentro de Compose todavía se usa localhost.
Desde fuera parece inocente: «Bueno, la base de datos está en mi máquina». Pero dentro de Compose, localhost es el contenedor de la aplicación, no el contenedor de la base de datos. Esto lleva a infinitos «Connection refused» e intentos de «ajustar el puerto», aunque el puerto no tiene la culpa. El host correcto es el nombre del servicio, es decir, postgres, redis, rabbitmq.
Error n.º 5: Los secretos «por un momento» se filtraron al Dockerfile/repositorio.
Normalmente comienza con la frase «solo lo voy a probar». Luego se commitea, se pushea, entra en el history de la imagen, y ya ningún git revert normaliza la situación. En la plantilla, los secretos deben vivir fuera: variables de entorno, archivos externos, enfoques *_FILE (si los usas). El Dockerfile debe permanecer limpio, como la conciencia del desarrollador en un mundo ideal (o al menos esforzarse).
Error n.º 6: en un mismo Dockerfile dejaron varios targets y empezaron a construir «lo que salga por defecto».
Si conviven runtime, development y runtime-layered, la compilación manual sin --target se convierte rápidamente en una lotería. En una plantilla reutilizable, esto se soluciona con una regla simple: el camino normal siempre construye runtime, y los demás targets se invocan por nombre.
GO TO FULL VERSION