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 buildships the compiler and sources. - Using a wildcard that matches several jars.
target/*.jarmust match exactly one file; disable the plain-jar output or name the file explicitly. - Forgetting
.dockerignore. Sendingtarget/,.git, ornode_modulesinto 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
ASto name stages,COPY --fromto move artifacts, and--targetto stop at a stage. - Cache dependencies by copying
pom.xmlfirst, and use BuildKit cache mounts for speed. - Use secret mounts rather than
ARGfor 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.