CicloUrbana is observable, knows which environment it lives in and works on its own. And it is still a JAR that somebody has to start by hand on a machine with Java 21 installed, the right time zone and the environment variables correctly set. That "somebody" and those conditions are the last hand-crafted link in the project: the reason it works on one server and not on another, and the reason deployment depends on a list of steps in one person's head.
This lesson removes it by packaging the application, its JRE and its configuration into a reproducible container image. We will look at why the Dockerfile everybody writes first is wrong, how to exploit Spring Boot's layered JAR so that rebuilding after changing one line takes seconds, which base image to choose, the buildpacks alternative, how the JVM behaves inside a container, the complete docker-compose.yml of the Ribalta network with PostgreSQL and its health checks wired to the probes from 07-01, and the security practices that stop you publishing an image with secrets inside it.
Contents
- Why containerise
- The minimum Docker concepts
- The naive
Dockerfileand why it is wrong - Spring Boot's layered JAR
- CicloUrbana's multi-stage
Dockerfile - Choosing the base image
- Buildpacks:
spring-boot:build-image - The JVM inside a container
- The Ribalta network's
docker-compose.yml - Configuring the application in the container
spring-boot-docker-composefor development- Native images with GraalVM
- Image security
- Publishing to a registry and tagging
- Common Mistakes and Tips
- Exercises
- Why containerise
A container packages the application and everything it needs in order to run — the JRE, the system libraries, the default configuration, the time zone — into a single immutable artefact. What it solves, concretely:
| Problem without a container | How the image solves it |
|---|---|
| "It works on my machine" | The runtime environment travels inside the artefact |
| Installing Java 21 on every server and keeping it up to date | The JRE is a layer of the image |
| A server with the wrong time zone | It is fixed in the image and identical everywhere |
| Deployment as a list of manual steps | docker run or a declarative manifest |
| Rolling back to the previous version | Start the image's previous tag |
| Running two versions at once to migrate | Two containers, with no dependency clashes |
And a strategic consequence: the image is the unit understood by Kubernetes (08-04), AWS managed services (08-03) and continuous delivery pipelines (08-05). Containerising is not an end in itself; it is the prerequisite for everything that comes next.
It is worth being honest about what it does not solve. A container does not isolate like a virtual machine — it shares the host's kernel — it does not fix an application with state on local disk, and it does not make bad configuration good: if the prod profile is not activated, it will be just as unactivated inside the container as outside it (07-02).
- The minimum Docker concepts
| Concept | What it is | In CicloUrbana |
|---|---|---|
| Image | An immutable read-only template holding a filesystem | ciclourbana:2.4.0 |
| Layer | Each Dockerfile instruction produces a stacked, cacheable layer |
The JRE layer, the dependencies layer, the code layer |
| Container | A running instance of an image, with a writable layer on top | The process serving port 8080 |
Dockerfile |
The recipe for building an image | At the root of the repository |
| Registry | A store of published images | Docker Hub, GHCR, ECR |
| Tag | An image's version name | 2.4.0, latest, sha-9f3a2b1 |
| Volume | Persistent storage outside the container's lifecycle | The PostgreSQL data |
| Network | The space where containers see each other by name | app reaches postgres over DNS |
The key idea of the whole chapter is layers. An image is a stack of read-only layers; when rebuilding, Docker reuses from the cache every layer before the first change and redoes only the ones after it. And when publishing, only the layers the registry does not already have are transferred. The whole of the optimisation in section 4 consists of putting what rarely changes at the bottom and what changes often at the top.
- The naive
Dockerfile and why it is wrong
Dockerfile and why it is wrongThis is the file almost everybody writes first:
FROM eclipse-temurin:21-jre
COPY target/ciclourbana-2.4.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]It works. And it has five serious problems:
| Problem | Consequence |
|---|---|
| The whole JAR is a single layer | Changing one line of StationService invalidates the full 60 MB: everything is rebuilt and uploaded |
| It requires having compiled beforehand | The build depends on the local Maven; nobody guarantees it matches the continuous integration one |
It runs as root |
An application vulnerability runs with the container's most privileged user |
| A full base image | 21-jre without a suffix drags in dozens of system packages that are never used: attack surface and weight |
ENTRYPOINT with java -jar |
Spring Boot has to unpack and resolve the JAR at every startup, and signals reach the process less reliably |
The first is the one that hurts most day to day. In a normal development cycle the dependencies change once a month and the code changes twenty times a day; with a monolithic JAR, each of those twenty changes rebuilds and transfers the whole of Spring Framework, Hibernate, Jackson and the PostgreSQL driver.
- Spring Boot's layered JAR
Spring Boot solves the problem by publishing the JAR already split into layers ordered from least to most volatile:
| Layer | Content | Change frequency |
|---|---|---|
dependencies |
Dependencies with stable versions | Very low |
spring-boot-loader |
The executable JAR's loader | Almost never |
snapshot-dependencies |
-SNAPSHOT dependencies |
Medium |
application |
Your code and your resources | Extremely high |
It is inspected and extracted with the JAR's own jarmode. In Spring Boot 3.3 and later:
java -Djarmode=tools -jar target/ciclourbana.jar list-layers
java -Djarmode=tools -jar target/ciclourbana.jar extract --layers --destination extractedIn earlier versions the mode was called layertools (java -Djarmode=layertools -jar app.jar extract); the concept is identical and it is worth knowing both names, because the documentation and the examples in circulation mix the two.
The result is a directory tree, one per layer, which are copied into the image in order. Since the application layer weighs a few hundred kilobytes and goes last, a code change rebuilds only that one: the build-and-publish cycle drops from minutes to seconds.
- CicloUrbana's multi-stage
Dockerfile
Dockerfile# ---------- Stage 1: build ----------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
# 1. The POM only: this layer stays cached as long as the dependencies do not change
COPY pom.xml .
RUN mvn -B dependency:go-offline
# 2. Now the code: it changes daily, but the dependencies are already downloaded
COPY src ./src
RUN mvn -B clean package -DskipTests
# 3. Split the resulting JAR into its layers
RUN java -Djarmode=tools -jar target/ciclourbana.jar extract --layers --destination extracted
# ---------- Stage 2: runtime ----------
FROM eclipse-temurin:21-jre-alpine AS runtime
# Unprivileged user: never run as root
RUN addgroup -S ciclo && adduser -S ciclo -G ciclo
WORKDIR /app
# Layers from least to most volatile: exploits the cache on every rebuild
COPY --from=build --chown=ciclo:ciclo /build/extracted/dependencies/ ./
COPY --from=build --chown=ciclo:ciclo /build/extracted/spring-boot-loader/ ./
COPY --from=build --chown=ciclo:ciclo /build/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=ciclo:ciclo /build/extracted/application/ ./
USER ciclo
EXPOSE 8080 8081
ENV TZ=Europe/Madrid \
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
ENTRYPOINT ["java", "-jar", "app.jar"]The decisions, one by one:
- Two stages. The first brings in Maven and the full JDK; the second starts from an image with only the JRE. The final image contains no Maven, no JDK, no source code and no
.m2repository: it goes from around 800 MB to around 200. COPY pom.xmlbeforeCOPY src. This is the central trick: as long as the POM does not change, thedependency:go-offlinelayer comes from the cache and the dependency download is skipped entirely. Copying everything together would make any code change download the whole internet again.-DskipTestsis correct here, even though it sounds like heresy after module 6: the tests already ran in the pipeline (08-05) before the image was built. Running them again inside the container doubles the time and, with Testcontainers, would require Docker inside Docker.extract --layersproduces the four directories from the previous section.- Unprivileged user.
addgroup/adduseris the Alpine syntax; on Debian-based images it would begroupadd/useradd. The--chownon eachCOPYavoids an extra layer just to change permissions. - The four
COPYinstructions in that exact order. This is where the whole optimisation materialises: a change inRentalServiceonly invalidates the last one. EXPOSE 8080 8081documents the API port and the management port from 07-01. It is informative; on its own it opens nothing.TZ=Europe/Madridavoids the time zone problem from 07-03: without it, a container runs in UTC and the nightly cron shifts.ENTRYPOINTin list form, not as a string. The string form starts the process under ash -c, which stays as PID 1 and does not forward signals: theSIGTERMof the graceful shutdown (01-05, 07-03) never reaches the JVM and the container dies abruptly after ten seconds.
Building and running:
docker build -t ciclourbana:2.4.0 .
docker run --rm -p 8080:8080 -e SPRING_PROFILES_ACTIVE=dev ciclourbana:2.4.0
- Choosing the base image
| Base image | Approximate size | Notes |
|---|---|---|
eclipse-temurin:21-jre |
~270 MB | Full Debian; the safe, best-documented option |
eclipse-temurin:21-jre-alpine |
~180 MB | Alpine; very widely used, but mind musl |
amazoncorretto:21-alpine |
~190 MB | Amazon's distribution, long support; a natural fit on AWS (08-03) |
bellsoft/liberica-openjre-debian:21 |
~200 MB | The one Spring's buildpacks use by default |
gcr.io/distroless/java21-debian12 |
~230 MB | No shell and no package manager: minimal surface |
The warning about Alpine. Alpine uses musl as its C library instead of glibc. Most Java applications work without any trouble, but there are two problem areas: libraries with native code (some cryptographic clients, compressors, netty-tcnative) may fail or perform worse, and certain DNS and name resolution scenarios behave differently. If the alpine image works with CicloUrbana's test suite run inside the container, go ahead; if a strange native error turns up, this should be your first hypothesis.
About distroless: it has no shell, so docker exec -it ... sh does not work. That is exactly what makes it secure — an attacker who gains execution has no shell either — and what makes it awkward to debug. It is the right choice when diagnostics are done through Actuator and centralised logs (09-05), which is precisely where this course is heading.
- Buildpacks:
spring-boot:build-image
spring-boot:build-imageSpring Boot can build the image with no Dockerfile at all, using Cloud Native Buildpacks:
The plugin inspects the project, detects that it is a Java application, picks a suitable JRE, applies layers by itself, creates an unprivileged user and adds calculated memory settings. The result is an OCI image ready to run.
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>ghcr.io/ribalta-council/ciclourbana:${project.version}</name>
<env>
<BP_JVM_VERSION>21</BP_JVM_VERSION>
<BPE_APPEND_JAVA_TOOL_OPTIONS>-XX:MaxRAMPercentage=75</BPE_APPEND_JAVA_TOOL_OPTIONS>
</env>
<publish>true</publish>
</image>
<docker>
<publishRegistry>
<username>${env.REGISTRY_USER}</username>
<password>${env.REGISTRY_TOKEN}</password>
</publishRegistry>
</docker>
</configuration>
</plugin>| Criterion | Your own Dockerfile |
Buildpacks |
|---|---|---|
| Control over the content | Total | Limited to the builder's options |
| Docker knowledge required | Medium | Almost none |
| Security updates to the base | Manual: you have to change the FROM |
Reapplied without recompiling with pack rebase |
| Best practices by default | Whatever you write | Non-root user, layers, SBOM, tuned memory |
| Reproducibility | High if you pin versions | Very high |
| Debugging the build | Direct | More opaque when something fails |
| Image size | Smaller if you look after it | Somewhat larger |
The honest recommendation: if the team has no Docker experience and wants best practices by default, buildpacks. If it needs fine control — a native library, an approved corporate base image, a specific vulnerability scan — the Dockerfile from section 5. Both paths are legitimate and both produce correct images; what is not legitimate is the naive Dockerfile from section 3.
- The JVM inside a container
There was a time when the JVM could not see the container's limits: it read the host machine's memory, sized the heap at a quarter of those 64 GB and the orchestrator killed the process for exceeding its 512 MB limit. That has been solved since Java 10: UseContainerSupport is on by default and the JVM reads the cgroups. There is no need to enable it.
What you do have to understand is how memory is divided up. -Xmx sets an absolute number that has to be revisited every time the container's limit changes; MaxRAMPercentage sets a proportion of the limit and adapts on its own:
| Setting | Effect |
|---|---|
-XX:MaxRAMPercentage=75 |
The heap uses at most 75 % of the container's limit |
-XX:+ExitOnOutOfMemoryError |
On an OutOfMemoryError the process terminates instead of limping along |
-XX:ActiveProcessorCount=N |
Forces the number of cores the JVM sees when the quota confuses the calculation |
Why 75 and not 100. A JVM does not consume only heap: there is metaspace, thread stacks, direct buffers, compiled code and the operating system itself. Leaving a 25 % margin stops the orchestrator killing the container with OOMKilled — a failure that is also hard to diagnose, because it leaves no trace in the application log.
JAVA_TOOL_OPTIONS is the variable to use, rather than putting the options in the ENTRYPOINT, for two reasons: the JVM reads it automatically, and it can be overridden when starting the container without rebuilding the image. Startup itself records it in the log (Picked up JAVA_TOOL_OPTIONS: ...), which serves as confirmation.
- The Ribalta network's
docker-compose.yml
docker-compose.ymlIn 04-02 we created a docker-compose.yml with PostgreSQL only. Now it is completed with the application:
services:
postgres:
image: postgres:16-alpine
container_name: ciclourbana-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ciclourbana
POSTGRES_USER: ciclourbana
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is missing}
TZ: Europe/Madrid
volumes:
- postgres-data:/var/lib/postgresql/data
networks: [ciclourbana-network]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ciclourbana -d ciclourbana"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
app:
build: .
image: ciclourbana:${VERSION:-2.4.0}
container_name: ciclourbana-app
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/ciclourbana
SPRING_DATASOURCE_USERNAME: ciclourbana
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is missing}
JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75"
ports:
- "8080:8080"
- "127.0.0.1:8081:8081" # management: reachable from the host only
networks: [ciclourbana-network]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8081/actuator/health/readiness"]
interval: 15s
timeout: 3s
retries: 3
start_period: 45s
deploy:
resources:
limits:
memory: 1g
volumes:
postgres-data:
networks:
ciclourbana-network:
driver: bridgeThe points to understand:
depends_onwithcondition: service_healthystops the application starting until PostgreSQL answerspg_isready. Without that condition,depends_ononly guarantees the start order, not that the service is ready, and CicloUrbana would fail to connect and enter a restart loop.${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is missing}makesdocker compose upfail immediately if the variable is not defined, instead of starting with an empty string. It is the same fail-early philosophy as 07-02.- The URL points at
postgres, not atlocalhost. Inside the Compose network, each service is reachable by name thanks to the internal DNS.localhostinside the application's container is the container itself. 127.0.0.1:8081:8081publishes the management port on the host only, not on every interface. It is the Docker translation of the practice from 07-01: Actuator never reachable from outside.- The application's
healthcheckqueries/actuator/health/readiness, the probe we wrote in 07-01. Here you can see why it was called "the piece the orchestrator needs": it is exactly the same mechanism Kubernetes will use in 08-04. start_period: 45sgives Spring's startup room — context, Flyway, pool — without the failures in that window counting as retries.limits.memory: 1gis what givesMaxRAMPercentage=75its meaning: with no declared limit, the percentage is computed against the host's memory.
The secrets live in a .env file next to the docker-compose.yml and outside Git:
# .env — NEVER commit this
POSTGRES_PASSWORD=a-long-and-random-password
JWT_SECRET=another-secret-of-at-least-32-characters
VERSION=2.4.0A committed .env is the same mistake as an application-prod.yml with credentials (07-02), made worse by the fact that it looks like a harmless infrastructure file. And it is worth knowing that a container's environment variables are visible through docker inspect to anyone who can talk to the Docker daemon: for real secrets, production uses the orchestrator's secrets mounted as files and read with spring.config.import: optional:configtree:/run/secrets/ (07-02).
- Configuring the application in the container
The rule is factor III of the 12-Factor App, already applied in 07-02: configuration comes from the environment. Inside a container that means the image is identical in every environment and the only thing that changes is the variables:
docker run --rm -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://db-ribalta:5432/ciclourbana \
-e SPRING_DATASOURCE_PASSWORD="$POSTGRES_PASSWORD" \
-e JWT_SECRET="$JWT_SECRET" \
ghcr.io/ribalta-council/ciclourbana:2.4.0The profile is never baked into the image. Writing ENV SPRING_PROFILES_ACTIVE=prod in the Dockerfile produces an image that is only good for production and breaks the principle from 07-02: the image tested in pre-production would no longer be the one deployed. The profile is decided at run time.
When the configuration is too large for variables, a file is mounted:
Spring finds it on its own, because ./config/ next to the JAR is one of the locations in the chain from 07-02, and :ro mounts it read-only.
And about shutdown: Docker sends SIGTERM and waits ten seconds before the SIGKILL. Since the application has a graceful shutdown with up to forty seconds of waiting (07-03), that window has to be widened or the container will die mid-request:
spring-boot-docker-compose for development
spring-boot-docker-compose for developmentIn 06-05 we met the module that starts the docker-compose.yml services alongside the application in development:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-docker-compose</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>When you run ./mvnw spring-boot:run, Spring brings up the declared services, detects PostgreSQL and configures spring.datasource.* on its own with the assigned port — just as @ServiceConnection did in the tests — and stops them when the application stops. Anyone who clones CicloUrbana needs to install nothing and remember no preliminary command.
Two precautions. The file it points at must be a development one (compose-dev.yml, with PostgreSQL only), not the production docker-compose.yml that also builds and starts the application itself: that would produce two CicloUrbanas fighting over port 8080. And lifecycle-management: start-only is preferable if it is annoying to have the containers stop every time, at the cost of having to stop them by hand.
- Native images with GraalVM
GraalVM compiles the application ahead of time into a native executable, with no JVM at run time:
./mvnw -Pnative native:compile # local binary, requires GraalVM installed
./mvnw spring-boot:build-image -Pnative # container image, nothing to install| Aspect | Traditional JVM | Native image |
|---|---|---|
| Startup time | 2-4 seconds | 40-90 milliseconds |
| Resident memory | 300-500 MB | 80-150 MB |
| Sustained performance | Better (the JIT optimises over time) | Somewhat worse under long loads |
| Compilation time | ~30 seconds | 5-15 minutes |
| Reflection and dynamic proxies | No restrictions | Must be declared up front |
| Diagnostic tooling | Complete | Limited |
Where it shines: serverless functions, scale to zero, very frequent startups, environments where memory is expensive. Where it does not pay off: an application like CicloUrbana, which starts once and runs for weeks, gains little and pays a ten-minute compilation on every build.
The technical obstacle is reflection: ahead-of-time compilation needs to know at build time every class that will be instantiated dynamically. Spring Boot 3 generates a large part of that metadata automatically, but your own code that uses reflection must declare it with RuntimeHints:
@Component
public class NativeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader cl) {
hints.reflection().registerType(NetworkSummary.class, MemberCategory.values());
}
}It is worth knowing about and worth knowing it exists; for CicloUrbana, the JRE image from section 5 is the right choice today.
- Image security
An image is an artefact that gets published and distributed. Everything that goes into it goes in forever: deleting a file in a later layer does not remove it from the earlier one, it is still there and can be extracted.
| Practice | Why |
|---|---|
Non-root user (USER ciclo) |
Limits the damage of an application vulnerability |
| Minimal base image | Fewer packages, fewer CVEs to patch |
| No secrets in layers | An ARG with a password stays in the image's history |
.dockerignore |
Stops .git, .env, target/ or keys being copied into the build context |
| Pinned versions | eclipse-temurin:21-jre-alpine, never latest |
| Periodic scanning | docker scout cves or Trivy in the pipeline (08-05) |
| Updating the base | Vulnerabilities appear after publishing: you have to rebuild |
The .dockerignore is more important than it looks. Without it, COPY . . puts the entire .git directory inside the image — with the whole history, including the secrets somebody committed and deleted afterwards — and also the .env we took such care not to commit. On top of that, the entire build context is transferred to the Docker daemon, which slows down every build.
And a warning about secrets during the build: ARG TOKEN followed by a RUN that uses it leaves the value in the image's metadata, visible with docker history. For credentials during the build there is RUN --mount=type=secret, which persists nothing in the layers.
- Publishing to a registry and tagging
docker tag ciclourbana:2.4.0 ghcr.io/ribalta-council/ciclourbana:2.4.0
docker push ghcr.io/ribalta-council/ciclourbana:2.4.0The tagging policy decides whether a deployment is reproducible:
| Tag | Use | Risk |
|---|---|---|
2.4.0 |
Semantic version, immutable | None: it is the one you deploy |
sha-9f3a2b1 |
The exact commit | None; it matches /actuator/info from 07-01 |
latest |
Convenience in development | High: nobody knows what it contains and it cannot be reproduced |
2.4 or 2 |
Moving aliases | Medium: they change under your feet |
The rule: always deploy by version or by commit, never by latest. With the sha-9f3a2b1 tag and /actuator/info from 07-01 returning that same hash, the question "what is running in Ribalta?" has an exact, verifiable answer. In 08-05 this tagging is generated automatically by the pipeline.
Common Mistakes and Tips
COPY . . before resolving the dependencies. Every code change downloads the whole Maven repository again. The pom.xml first, the src afterwards.
The JAR as a single layer. Changing one line invalidates 60 MB. Extract the layers with the jarmode.
Running as root. It is the default and it has to be changed explicitly.
ENTRYPOINT in string form. The intermediate sh -c swallows the SIGTERM and the graceful shutdown never happens.
depends_on without condition: service_healthy. It guarantees the start order, not that the database is ready.
Using localhost in the database URL inside the container. Every container has its own localhost; you have to use the service name.
Not declaring a memory limit. MaxRAMPercentage is computed against the host's memory and the container ends up OOMKilled.
Baking SPRING_PROFILES_ACTIVE=prod into the image. The image stops being the same one in every environment and the principle from 07-02 is broken.
Committing the .env or having no .dockerignore. Both put secrets where they must not be: in Git or inside the image's layers.
Tip: pin the versions of every base image, and schedule periodic rebuilds so that the base's security patches are picked up.
Tip: test the image, not just the JAR. Starting the container and querying /actuator/health/readiness in the pipeline catches time zone, permission and variable problems that no JVM test can see.
Tip: measure the rebuild time. If changing one line takes more than thirty seconds to produce a new image, the layer order is wrong.
Exercises
Exercise 1: reviewing a real Dockerfile
Find every problem in this file, explain the consequence of each one and write the corrected version.
FROM openjdk:latest
WORKDIR /app
COPY . .
RUN mvn clean package
ARG DB_PASSWORD
ENV SPRING_DATASOURCE_PASSWORD=$DB_PASSWORD
ENV SPRING_PROFILES_ACTIVE=prod
EXPOSE 8080
ENTRYPOINT java -jar target/ciclourbana-2.4.0.jarExercise 2: a complete environment with Compose
Write a compose-pre.yml for the council's pre-production environment with: PostgreSQL 16 with a volume and a health check; CicloUrbana on the pre profile, limited to 1 GB, waiting for the database to be healthy, with the management port reachable from the host only and its own health check against readiness; secrets from .env; and a shutdown grace period consistent with the 40 seconds from section 13 of 07-03. Explain the order in which everything starts and what happens if PostgreSQL takes a minute to become ready.
Exercise 3: the image that takes four minutes
The team complains that changing one line in StationService and seeing the result in a container takes four minutes. The Dockerfile is multi-stage and correct apart from the build stage:
Diagnose the problem, propose the fix and estimate roughly how long it should take afterwards. Also say what else you would check if it still took more than a minute after the fix.
Solutions
Solution 1
Nine problems:
| # | Problem | Consequence |
|---|---|---|
| 1 | FROM openjdk:latest |
An unpinned image, and openjdk is discontinued on top of that; every build may give a different JVM |
| 2 | A single stage with Maven | The final image drags in the JDK, Maven and the .m2 repository: ~800 MB to run 60 MB |
| 3 | COPY . . before the dependencies |
No useful cache: any code change downloads everything again |
| 4 | No .dockerignore |
It copies .git, .env and target/ into the image |
| 5 | ARG DB_PASSWORD + ENV |
The password stays in the image's history, visible with docker history |
| 6 | ENV SPRING_PROFILES_ACTIVE=prod |
The image only works for production; "one artefact, many environments" is broken |
| 7 | It runs as root |
The whole container surface at maximum privilege |
| 8 | ENTRYPOINT in string form |
sh -c as PID 1: the SIGTERM never reaches the JVM and there is no graceful shutdown |
| 9 | No JAR layers and no JVM settings | Slow rebuilds and a risk of OOMKilled |
The fix is the Dockerfile from section 5, with two qualifications about the exercise. The password is not passed at build time in any form: it is runtime configuration and is injected as a variable when the container starts; if a credential really were needed during the build — for a private Maven repository, say — the correct form is RUN --mount=type=secret,id=maven, which leaves no trace in the layers. And the profile disappears from the Dockerfile entirely, moving to docker run -e SPRING_PROFILES_ACTIVE=... or to Compose.
Solution 2
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ciclourbana
POSTGRES_USER: ciclourbana
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is missing}
TZ: Europe/Madrid
volumes:
- postgres-pre:/var/lib/postgresql/data
networks: [pre-network]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ciclourbana -d ciclourbana"]
interval: 10s
timeout: 5s
retries: 6
start_period: 20s
app:
image: ghcr.io/ribalta-council/ciclourbana:${VERSION:?VERSION is missing}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: pre
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/ciclourbana
SPRING_DATASOURCE_USERNAME: ciclourbana
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is missing}
JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
TZ: Europe/Madrid
ports:
- "8080:8080"
- "127.0.0.1:8081:8081"
networks: [pre-network]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8081/actuator/health/readiness"]
interval: 15s
timeout: 3s
retries: 3
start_period: 45s
stop_grace_period: 50s
deploy:
resources:
limits:
memory: 1g
volumes:
postgres-pre:
networks:
pre-network:Start order. Compose creates the network and the volume; it starts postgres; during the first 20 seconds (start_period) the pg_isready failures do not count; when it answers, the service becomes healthy; only then does app start, resolving postgres over the internal DNS, applying the Flyway migrations and bringing up the context; for 45 seconds its own healthcheck does not penalise it, and once it answers /actuator/health/readiness with 200 the service becomes healthy and can receive traffic.
If PostgreSQL takes a minute, the behaviour is still correct: with interval: 10s, retries: 6 and start_period: 20s there is enough margin and app simply waits. If it exceeded that margin, PostgreSQL would be marked unhealthy, app would not start at all — which is the desired behaviour: better not to start than to start without a database — and docker compose ps would show clearly which service is failing.
stop_grace_period: 50s is the piece that makes the shutdown chain coherent: the application needs up to 40 seconds to finish requests and drain the executors (07-03), so giving it only Docker's default 10 seconds would cut the graceful shutdown right in half. The general rule is stop_grace_period > timeout-per-shutdown-phase > await-termination-period.
Solution 3
The diagnosis. COPY . . copies the code before resolving the dependencies, so the layer that runs mvn package is invalidated by any file change. Docker cannot reuse anything: every build downloads the whole Maven dependency tree again — Spring Boot, Hibernate, Jackson, Spring Security, JJWT, MapStruct, the drivers — from scratch. Those four minutes are, almost entirely, repeated downloads of artefacts that were already downloaded yesterday.
The fix is to split the copy into two steps, with the dependency resolution in between:
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests
RUN java -Djarmode=tools -jar target/ciclourbana.jar extract --layers --destination extractedNow a change in StationService invalidates the COPY src layer and the ones after it, but the dependencies layer comes from the cache. The time drops to however long compiling plus packaging takes: between twenty and forty seconds on a project the size of CicloUrbana. And since the final image's dependencies and spring-boot-loader layers do not change either, publishing to the registry transfers a few hundred kilobytes instead of sixty megabytes.
If it still took more than a minute after the fix, there are four things to check, in this order:
- Are the tests running? Without
-DskipTests,mvn packageruns the whole module 6 suite, Testcontainers included, which would need Docker inside Docker. Tests belong in the pipeline, before building the image. - Is there a
.dockerignore? Without one, the build context includes.gitandtarget/, and transferring them to the daemon before starting can take tens of seconds. - Is the cache being invalidated some other way? A
COPYof a file that always changes — a timestamp, a generated file — above the dependencies stage has the same effect as the original problem. - Does the cache exist at all? In continuous integration, each run usually starts on a clean machine with no layer cache; there the answer is a layer cache of its own (
--cache-from,cache-to) or a persistent volume for the.m2repository viaRUN --mount=type=cache,target=/root/.m2, which is the modern form and the one that works best in pipelines (08-05).
Conclusion
CicloUrbana is no longer a JAR that somebody starts by hand: it is a reproducible image that runs identically on a developer's laptop, on the pre-production server and on the Ribalta council's infrastructure. You know what a container solves and what it does not, and you handle the concepts that underpin it — image, layer, container, registry, tag, volume, network — with the central idea that layers are cached and that this is why what rarely changes goes at the bottom and what changes often goes at the top.
You know why the three-line Dockerfile is wrong — monolithic layer, dependence on the local Maven, root, a fat image, signals that never arrive — and you have written the one that is right: multi-stage, with the pom.xml copied before the src so the dependencies come from the cache, with the JAR split into its four layers by the tools jarmode, with an unprivileged user, a fixed time zone, memory settings through JAVA_TOOL_OPTIONS and an ENTRYPOINT in list form that does let SIGTERM reach the graceful shutdown. You know the base images and their trade-offs, including the warning about musl on Alpine and the price of distroless, and you know there is a path with no Dockerfile — the buildpacks of spring-boot:build-image, with pack rebase to patch the base without recompiling — along with clear criteria for choosing between the two.
You understand how the JVM behaves inside a container: UseContainerSupport no longer needs enabling, but MaxRAMPercentage=75 does, and that 25 % margin is what avoids the silent OOMKilled. You have the complete docker-compose.yml of the Ribalta network, with PostgreSQL 16, a named volume, its own network, depends_on: condition: service_healthy, the management port published only on 127.0.0.1, secrets in a .env outside Git and — the piece that links this lesson to the module's first — a HEALTHCHECK that queries /actuator/health/readiness. You know how to configure the application from the environment without ever baking the profile into the image, how to mount an external read-only YAML and how to widen stop_grace_period so that the graceful shutdown fits. And you have spring-boot-docker-compose bringing up the database in development, the overview of GraalVM with its real timings and its RuntimeHints, the list of image security practices — non-root user, minimal base, zero secrets in layers, .dockerignore, scanning with docker scout or Trivy — and a tagging policy that makes it verifiable which version is running in Ribalta.
Up to here, everything we have built is a single application: a well-made monolith, tested, observable, configurable and now containerised. It is a perfectly respectable architecture and, for a municipal bike network, probably the correct one. But sooner or later somebody asks the question: what if the billing module had its own lifecycle? What if the stations team deployed without coordinating with the rentals team? What if we had to scale only the part that queries availability, which gets a hundred times more traffic than the rest? The next lesson, Spring Boot and Microservices, answers those questions honestly: what problem microservices really solve, what you pay for them, how you decide where to cut, what breaks when each service has its own database, and why the first correct answer is nearly always modularise the monolith.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
