Topic 6.1
Multi-Stage Builds for Maven/Gradle
In one line
Compile your app INSIDE Docker (so nobody needs Maven/Java installed locally to build it) while keeping the final shipped image free of the entire build toolchain.
Think of it like this
A professional kitchen's prep area versus its plating counter. All the messy work — chopping, mixing bowls, flour everywhere — happens in the prep area (the BUILD stage). Only the finished, plated dish (the compiled JAR) makes it to the customer-facing counter (the FINAL image) — the customer never sees or pays for the mess of the kitchen equipment itself.
Key ideas
- 01
A MULTI-STAGE build has multiple
FROMinstructions in ONE Dockerfile, each starting a new, independent stage. Later stages can selectivelyCOPY --from=<earlier-stage>specific files out of an earlier stage — everything else from that earlier stage (the compiler, cached build files, source code) is simply LEFT BEHIND and never becomes part of the final image. - 02
The typical two-stage Java pattern: STAGE 1 uses a
maven(orgradle) image containing a full JDK and build tool, runsmvn packageto produce a JAR. STAGE 2 starts FRESH from a minimal JRE-only base image andCOPY --from=buildjust the resulting JAR — the final image contains ZERO trace of Maven, the JDK compiler, or your raw source code. - 03
This solves two real problems at once: (1) reproducible builds — anyone with Docker installed can build your app identically, without needing a specific local Maven/JDK version installed at all; (2) final image size and attack surface — a build toolchain can easily be hundreds of MB, none of which your running app actually needs.
- 04
The build-stage layers still benefit from the SAME caching principles as Topic 2.2 — copy
pom.xmland runmvn dependency:go-offlinebefore copying source code, exactly as before, just now happening as one stage within a larger multi-stage file. - 05
You can have MORE than two stages — e.g. a separate stage for running tests that the final image never includes at all, letting
docker builditself double as your CI's test-runner step if you want that.
In your stack
- →
This IS essentially the standard, idiomatic way any serious Java team containerizes a Spring Boot app today — a single Dockerfile that both builds AND packages the app, with no separate 'first run mvn package locally, then docker build' two-step process required at all.
Code & diagrams
Two stages: a full JDK+Maven builder, and a minimal JRE-only final image.
# ---- STAGE 1: build ----
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
# Dependency layer first — cached across builds unless pom.xml changes (Topic 2.2)
COPY pom.xml .
RUN mvn dependency:go-offline
# Source code layer — changes every commit, cheap to rebuild on top of cached deps
COPY src ./src
RUN mvn package -DskipTests
# ---- STAGE 2: final runtime image ----
FROM eclipse-temurin:17-jre
WORKDIR /app
# Pull ONLY the built jar out of the build stage — nothing else comes along
COPY --from=build /app/target/*.jar app.jar
RUN useradd -m -u 1000 appuser
USER appuser
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]Only the jar crosses the bridge between stages — everything else in Stage 1 is discarded.
Explain it without notes
Why does the FINAL image in a multi-stage build contain zero trace of Maven, even though Maven was genuinely used to build the app?
A teammate says 'we don't need multi-stage builds, we just run mvn package on our laptop first, then Docker just copies the jar in.' What real problem does that workflow have that multi-stage solves?
Practice
Convert a Dockerfile that assumes a pre-built jar (Topic 2.1's version) into a proper multi-stage build that compiles from source.
Build your multi-stage Dockerfile and run docker images to compare the FINAL image's size against the size of the intermediate maven:3.9-eclipse-temurin-17 build-stage image alone (docker images | grep maven).
Trade-offs
- ↔
Multi-stage builds mean Docker itself now downloads and caches build-tool images (Maven, etc.) locally, using disk space for the BUILD stage's layers even though they never ship — a reasonable, standard trade for reproducible builds and a lean final image; that disk usage can be reclaimed anytime with
docker system prune.
Done when you can
I can write a two-stage Dockerfile that builds AND packages a Java app from source.
I've personally compared the final image size against the build-stage image size.
I understand exactly why only explicitly-copied files cross between stages.