07 — Multi-stage builds
Read this first: this lesson explains why a production image should not contain the tools that built it, and how one Dockerfile uses several base images to arrange that. You already used a multi-stage build in lesson 06 without it being explained — this is the explanation, with measurements.
Time: about 30 minutes. Assumes lesson 06.
The problem
To build a Spring Boot application you need a JDK, Maven, your source, and a populated ~/.m2. To
run it you need a JRE and a jar.
If you build in the image you ship, you ship all of it: a compiler, a build tool, your source code, and every test dependency — sitting in production, for no reason, on every server, forever.
The shape
A Dockerfile can contain several FROM lines. Each starts a new stage, and a later stage can
copy files out of an earlier one. Only the last stage becomes the image you ship.
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
COPY src ./src
RUN mvn -B -q package -DskipTests
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY /build/target/greeter-api.jar ./
ENTRYPOINT ["java", "-jar", "greeter-api.jar"]
The magic is one flag: COPY --from=build reaches into the earlier stage and takes only what it
names. Everything else that stage produced — Maven, the JDK, your .java files, the whole target/
directory — is discarded.
Note the runtime base is 21-jre-alpine. JRE, not JDK. The shipped image has no compiler at all.
Measure it
Predict: how much bigger is the build stage than the image you ship? Write down a guess.
You can build any single stage with --target:
docker build --target build -t greeter-buildstage .
docker image inspect greeter-buildstage --format '{{.Size}}'
Measured on this toy app:
| Image | Size |
|---|---|
The build stage (Maven + JDK + source + target/) | 588 MB |
| The runtime image actually shipped (JRE + jar) | 257 MB |
| The React frontend's runtime image (nginx + static files) | 48 MB |
The API image is well under half the size of the thing that produced it — and this is a toy with six dependencies. On a real service the gap is far wider.
The frontend is the more dramatic case. Its build stage is a full Node toolchain with every
devDependency installed; what ships is 48 MB of nginx and a folder of static files. The
production frontend image contains no Node at all, because a built React app genuinely does not
need any.
Layers within the jar
The real backend does one more thing, and the toy app copies it because it is the single best trick in Java containerisation.
A Spring Boot jar is mostly dependencies that change rarely, plus a little application code that changes on every commit. Shipped as one file, one character of your code invalidates the whole thing. Boot can take it apart:
RUN java -Djarmode=tools -jar target/greeter-api.jar extract --layers --destination extracted
That produces four directories, which are then copied slowest-changing first:
COPY /build/extracted/dependencies/ ./
COPY /build/extracted/spring-boot-loader/ ./
COPY /build/extracted/snapshot-dependencies/ ./
COPY /build/extracted/application/ ./
This is lesson 06's cache rule applied inside a jar. Your application classes change hourly; your dependency jars change monthly. Splitting them into separate layers means a routine deploy pushes and pulls only the last, smallest layer — everything above it is already on the registry and already on the server.
backend/Dockerfile has these exact four lines.
Break it on purpose: ship the toolchain
Build the naive single-stage version from lesson 06 and compare:
docker images
Then ask yourself what is actually inside the bigger one, and answer honestly: Maven, a JDK, your complete source code, and your build output. On a server reachable from the internet.
Size is the visible cost. The one that matters more is that you have put a compiler and your source into production, and neither has any business being there.
A caution worth stating
Multi-stage builds are not free complexity. If your build and runtime genuinely need the same things, one stage is the honest answer. Reach for a second stage when you can name what you are leaving behind — here, "Maven, the JDK, and the source".
Recap
- Several
FROMlines make several stages; only the last is shipped. COPY --from=<stage>takes named files out of an earlier stage and leaves the rest.- Measured here: 588 MB build stage → 257 MB runtime image, and a frontend that ships nginx with no Node at all.
- Extract the Boot jar into layers so a routine deploy moves only your application classes.
Next: 08 — Compose, where one container becomes a stack.