Skip to main content

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 --mount=type=cache,target=/root/.m2 mvn -B -q dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests

FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=build /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:

ImageSize
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 --from=build /build/extracted/dependencies/ ./
COPY --from=build /build/extracted/spring-boot-loader/ ./
COPY --from=build /build/extracted/snapshot-dependencies/ ./
COPY --from=build /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 FROM lines 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.