Building a Java application needs a JDK, Maven, the whole dependency tree and a build cache. Running it needs a JRE and a jar. Those are very different sets of things, and a single-stage Dockerfile ships both.
A multi-stage build is one Dockerfile with several FROM lines. Each starts a new
stage with a fresh filesystem; only the last one becomes the image, and nothing
from an earlier stage is in it unless you explicitly copy it out.
Measured on the API image in this track:
$ docker build --target build -t pizza-api-buildstage .
$ docker image ls
REPOSITORY TAG SIZE
pizza-api-buildstage latest 957MB ← the build stage
pizza-api dev 460MB ← what actually shipsHalf a gigabyte of JDK, Maven and source that never leaves your laptop.
The shape
FROM eclipse-temurin:21-jdk AS build
WORKDIR /build
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -B dependency:go-offline || true
COPY src/ src/
RUN ./mvnw -B clean package -DskipTests
...
FROM eclipse-temurin:21-jre AS runtime
...
WORKDIR /app
...
COPY /build/extracted/application/ ./
...
ENTRYPOINT ["java", "-jar", "app.jar"]Two things are doing the work.
AS build names the stage so it can be referred to later. Without a name you would
use its index — --from=0 — which breaks the moment someone inserts a stage above
it.
COPY --from=build copies out of that stage instead of out of the build context.
This is the door between the two, and it is the only door.
The same idea for a frontend
More dramatic, because a built single-page app is just files:
FROM node:22-alpine AS build
WORKDIR /app
...
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY /app/dist /usr/share/nginx/html
EXPOSE 8080The runtime stage has no Node in it at all. The result:
$ docker history pizza-web:dev
SIZE CREATED BY
0B EXPOSE map[8080/tcp:{}]
1.09MB COPY /app/dist /usr/share/nginx/html
2.86kB COPY nginx.conf /etc/nginx/conf.d/default.conf
0B USER 101
37.4MB RUN ... nginx packages ...1.09 MB of application in a 50 MB image. The 232 MB of
node_modules the build needed is nowhere in it.
Build early, ship late
Everything expensive belongs in an early stage. The output of the last stage is your bill.
The API build takes this further than just "copy the jar". Spring Boot's jar is a nested archive — your classes plus every dependency jar inside one file — so a one-line code change produces a new 60 MB jar and therefore a new 60 MB layer to push and pull. Boot ships a tool to unpack it into layers by how often they change:
RUN java -Djarmode=tools -jar target/*.jar extract --layers --destination extracted
RUN mv extracted/application/*.jar extracted/application/app.jarCOPY /build/extracted/dependencies/ ./
COPY /build/extracted/spring-boot-loader/ ./
COPY /build/extracted/snapshot-dependencies/ ./
COPY /build/extracted/application/ ./Most stable first, most volatile last — the same principle as lesson 6, applied to the image's
own layers rather than to the build. The payoff is visible in docker history:
336kB COPY /build/extracted/application/ ./
0B COPY /build/extracted/snapshot-dependencies/ ./
0B COPY /build/extracted/spring-boot-loader/ ./
120MB COPY /build/extracted/dependencies/ ./A code change now produces a new 336 kB layer, not a new 120 MB one. That is what a deployment actually spends its time transferring.
The mv is not incidental either. The extracted jar keeps its versioned name, and
ENTRYPOINT is exec form — no shell, so no globbing, so
java -jar /app/*.jar fails with Unable to access jarfile /app/*.jar.
Renaming during the build is what lets the entrypoint name a file without hard-coding a version
number somebody has to remember to bump.
--target
You can stop at any stage:
docker build --target build -t pizza-api-buildstage .Useful for measuring, as above; useful for debugging a build that fails somewhere in the middle,
since the stage is a real image you can docker run --entrypoint sh into and look
around; and useful for a test stage that CI builds and a deployment skips.
Compose can point at one directly:
build:
context: ./pizza-springboot-backend
target: runtimeTwo things worth knowing
Stages are built lazily. Docker builds only the stages the target actually depends on. A stage nothing copies from is skipped, so it costs nothing to leave one in the file.
You can COPY --from an image you never built.
COPY --from=alpine:3.21 /etc/ssl/certs /etc/ssl/certs pulls files straight out of a
published image. Handy for grabbing CA certificates or a static binary into a
scratch-based image.