Docker – Multi-Stage Builds

July 3, 20263 min readUpdated 8/21/2026

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 ships

Half 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 --from=build --chown=pizza:pizza /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 --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080

The 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.jar
COPY --from=build --chown=pizza:pizza /build/extracted/dependencies/ ./
COPY --from=build --chown=pizza:pizza /build/extracted/spring-boot-loader/ ./
COPY --from=build --chown=pizza:pizza /build/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=pizza:pizza /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: runtime

Two 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.

Next: deciding what that final FROM should be.