Docker Multi Stage Build

Use multi-stage Docker builds to compile in a full toolchain image and ship a small runtime image, with Maven, Spring Boot, and Go examples.

A multi-stage build uses several FROM instructions in one Dockerfile. Early stages contain compilers and build tools; the final stage copies only the build output into a minimal runtime image. The result is a much smaller, safer image, and the build stays reproducible because it no longer depends on a pre-built artifact from your machine.

The Problem It Solves

Building a Java app needs Maven and a full JDK, which add hundreds of megabytes. Running it needs only a JRE and the jar. Without multi-stage builds you either ship the build tools to production or maintain a separate script that builds outside Docker. Multi-stage builds do both in one file.

Maven and Spring Boot Example

# ---- Stage 1: build ----
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace

# Resolve dependencies first so this layer is cached until pom.xml changes
COPY pom.xml .
RUN mvn -B -q dependency:go-offline

# Now copy sources and build
COPY src ./src
RUN mvn -B -DskipTests package

# ---- Stage 2: runtime ----
FROM eclipse-temurin:21-jre
RUN groupadd --system app && useradd --system --gid app --no-create-home app
WORKDIR /app

COPY --from=build /workspace/target/*.jar app.jar

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0"
USER app
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]

Build and compare sizes:

docker build -t demo-app:1.0 .
docker images demo-app
# REPOSITORY   TAG   IMAGE ID       CREATED          SIZE
# demo-app     1.0   a1b2c3d4e5f6   10 seconds ago   ~290MB   (varies by app)

Only the final stage becomes the image. The Maven stage, its local repository, and the source code are discarded and never appear in the result. COPY --from=build is what moves the jar across.

Faster Builds with a Cache Mount

BuildKit (the default builder in current Docker) supports cache mounts that persist the Maven repository between builds, even when pom.xml changes:

RUN --mount=type=cache,target=/root/.m2 \
    mvn -B -DskipTests package

The cache lives on the build host and is not stored in the image.

Building Only One Stage

Name stages with AS and select one with --target, which is useful for running tests or building a debug image:

FROM maven:3.9-eclipse-temurin-21 AS build
# ...
FROM build AS test
RUN mvn -B test

FROM eclipse-temurin:21-jre AS runtime
COPY --from=build /workspace/target/*.jar /app/app.jar
docker build --target test -t demo-app:test .     # runs tests, stops at the test stage
docker build --target runtime -t demo-app:1.0 .

A Go Example: Tiny Final Image

Compiled languages can ship a static binary on a nearly empty image:

FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

FROM scratch
COPY --from=build /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]

scratch is an empty image, so the container holds only your binary. It has no shell, so debugging requires other approaches, and the binary must be statically linked (hence CGO_ENABLED=0). If your app needs CA certificates or time zone data, copy them from the build stage or use a minimal image such as a distroless base.

Copying from Other Images

COPY --from can reference an external image as well as a stage:

COPY --from=nginx:1.27 /etc/nginx/nginx.conf /tmp/nginx.conf

Common Mistakes

  • Copying the sources before the dependency step. COPY . . before dependency resolution invalidates the cache on every code change.
  • Leaving build tools in the final stage. Starting the last stage FROM build ships the compiler and sources.
  • Using a wildcard that matches several jars. target/*.jar must match exactly one file; disable the plain-jar output or name the file explicitly.
  • Forgetting .dockerignore. Sending target/, .git, or node_modules into the build context slows builds and pollutes layers.
  • Putting secrets in build stages. Build arguments and files used in earlier stages can remain in the build cache and history; use BuildKit secret mounts instead.

Best Practices

  • Name stages (AS build, AS runtime) and keep the final stage minimal.
  • Order build steps from least to most frequently changed: dependencies, then sources.
  • Use BuildKit cache mounts for package manager caches.
  • Run tests in a dedicated stage so a failing test stops the image build.
  • Run the runtime stage as a non-root user.

Interview Questions

1. What is a multi-stage build and why use it?

It is a Dockerfile with multiple FROM stages where only the last stage becomes the image. You build in a full toolchain stage and copy just the artifacts into a slim runtime stage, which reduces size, attack surface, and the need for external build scripts.

2. How do you copy an artifact from one stage to another?

Use COPY --from=<stage-name-or-index> <src> <dest>, for example COPY --from=build /workspace/target/app.jar /app/app.jar.

3. How do you build only a specific stage?

Use docker build --target <stage> .. Docker builds that stage and everything it depends on, and tags the result.

4. How do multi-stage builds help with layer caching?

You can copy dependency manifests and resolve dependencies in an early step that stays cached until those files change, then copy source code in a later step. The runtime stage is also independent of the build tool layers, so it rebuilds quickly.

5. What is the difference between a JDK and a JRE image, and which stage uses which?

The JDK includes compilers and development tools needed to build; the JRE includes only what is needed to run. The build stage uses a Maven/JDK image and the runtime stage uses a JRE image.

6. How can you keep build-time secrets out of the final image?

Only the final stage's contents ship, so secrets used in earlier stages are not in the image filesystem, but they can still leak through build cache or history if passed as ARG or copied files. Use RUN --mount=type=secret so the secret is available only during that command.

Summary

  • Multi-stage builds separate build tooling from the runtime image in a single Dockerfile.
  • Use AS to name stages, COPY --from to move artifacts, and --target to stop at a stage.
  • Cache dependencies by copying pom.xml first, and use BuildKit cache mounts for speed.
  • Use secret mounts rather than ARG for build-time credentials.

Continue Learning

Previous: Dockerize Spring Boot App | Next: Docker Security

Was this guide helpful?

CodeWithVenu is free to read, with no paywall. If this guide saved you time or helped you prepare, a small voluntary contribution helps me write more practical tutorials, architecture guides, and interview-prep content.