Back to Docker: Images, Containers, Multi-Stage
Q3Question 3 of 10

Multi-Stage Docker Build and How It Reduces Final Image Size

Key Concepts
  • A multi-stage build uses multiple FROM instructions, each starting an independent build stage.
  • The build stage uses a heavy base with the full toolchain; the runtime stage uses a slim base.
  • COPY --from=build copies only the finished artifact into the runtime stage, discarding everything else.
  • Only the final stage becomes the shipped image, so build tools, source, and caches are left behind.
  • A single-stage Java image can be over 1 GB; a multi-stage equivalent on a slim JRE is often 200-300 MB.
  • Smaller images pull faster, start faster, and have a smaller attack surface.

Definition

A multi-stage build compiles in a heavy toolchain stage and copies only the finished artifact into a slim runtime stage, so the shipped image contains the app but none of the build tools.
  • Multi-stage build – a Dockerfile with multiple FROM instructions, each starting an independent stage.
  • Build stage – uses a heavy base image (Maven + JDK) to compile and produce the artifact.
  • Runtime stage – uses a slim base image (JRE only) and copies just the artifact from the build stage with COPY --from=build.
  • Discarded stages – everything in build stages (toolchain, source, caches) is not part of the final image.
  • Size reduction – removes the largest avoidable bloat: the compiler, build tool, and dependency cache.

Imagine the National Bank of India printing cheque books. The printing workshop (build stage) is full of heavy presses, ink drums, and plates. You would never ship the entire workshop to the branch. Instead you take only the finished cheque books (the artifact) and send those in a light package (the slim runtime image). The workshop stays behind and is discarded from the shipment.

Understanding the Concepts

What is a single-stage build and why is it a problem?

A single-stage build bakes everything into one image. If you build on a Maven + JDK base, the final image keeps Maven, the full compiler, source, and dependency caches, even though none of that is needed at runtime.

DOCKERFILE
# WRONG: single-stage build. Final image is over 1 GB.
FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY pom.xml .
COPY src/ src/
RUN mvn clean package -DskipTests
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "target/transfer-service.jar"]
# This image contains Maven, javac, source, and the .m2 cache.
# Size: ~1.2 GB

What is a multi-stage build?

A multi-stage build splits the Dockerfile into stages with multiple FROM instructions. Only the final stage becomes the shipped image.

DOCKERFILE
# CORRECT: multi-stage build. Final image is ~250 MB.

# Stage 1: BUILD (heavy toolchain)
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:resolve          # Cache dependencies
COPY src/ src/
RUN mvn clean package -DskipTests   # Produce the JAR

# Stage 2: RUNTIME (slim JRE only)
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/transfer-service.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# This image has: JRE + app.jar. No Maven, no compiler, no source.
# Size: ~250 MB
BASH
# Build the multi-stage image
docker build -t registry.bank.local/transfer-service:1.2.0 .

# Check image size
docker images registry.bank.local/transfer-service:1.2.0
# REPOSITORY                                   SIZE
# registry.bank.local/transfer-service:1.2.0   247MB

How does COPY --from work?

COPY --from=build copies files from a named earlier stage into the current stage. This is the mechanism that transfers only the artifact.

DOCKERFILE
# Copy only the JAR from the build stage
COPY --from=build /app/target/transfer-service.jar app.jar

# You can also copy from a specific stage number
COPY --from=0 /app/target/transfer-service.jar app.jar

# Or copy from an external image
COPY --from=nginx:alpine /etc/nginx/nginx.conf /etc/nginx/

How does layer caching help across stages?

The build stage benefits from caching too. Copy the dependency manifest (pom.xml) and resolve dependencies before copying source, so a code change only rebuilds the cheap top layers.

DOCKERFILE
# Dependencies are cached unless pom.xml changes
COPY pom.xml .
RUN mvn dependency:resolve    # Cached layer

# Source changes only invalidate this layer and below
COPY src/ src/
RUN mvn clean package         # Re-runs on every code change

How do single-stage and multi-stage compare?

| Aspect | Single-Stage | Multi-Stage |
|---|---|---|
| Final image contains | App + tools + source + caches | App + runtime only |
| Image size (Java) | 700 MB - 1.2 GB | 200 - 300 MB |
| Attack surface | Large (compiler, shell, tools) | Small (runtime only) |
| Pull speed | Slow | Fast |
| Build complexity | One FROM | Multiple FROM |
| Layer caching | Same | Same (per stage) |

Distroless images contain only the runtime and the app, with no shell or package manager. jlink builds a custom trimmed Java runtime.

DOCKERFILE
# Even smaller: distroless base
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:resolve
COPY src/ src/
RUN mvn clean package -DskipTests

FROM gcr.io/distroless/java21-debian12
COPY --from=build /app/target/transfer-service.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
# No shell, no apt, no bash. ~180 MB.
DOCKERFILE
# Custom JRE with jlink for minimal runtime
FROM eclipse-temurin:21-jdk AS jre-build
RUN jlink --add-modules java.base,java.sql,java.logging,java.management \
    --strip-debug --no-man-pages --output /custom-jre

FROM debian:bookworm-slim
COPY --from=jre-build /custom-jre /opt/java
COPY --from=build /app/target/transfer-service.jar /app.jar
ENTRYPOINT ["/opt/java/bin/java", "-jar", "/app.jar"]
# Custom JRE: ~60 MB instead of 180 MB.

What are common mistakes?

DOCKERFILE
# WRONG: copying the entire project into the runtime stage.
FROM eclipse-temurin:21-jre
COPY --from=build /app /app    # Copies source, caches, and everything!
ENTRYPOINT ["java", "-jar", "/app/target/transfer-service.jar"]
# Size: almost as large as single-stage.

# CORRECT: copy only the artifact.
FROM eclipse-temurin:21-jre
COPY --from=build /app/target/transfer-service.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
DOCKERFILE
# WRONG: not separating dependency resolution from source copy.
FROM maven:3.9-eclipse-temurin-21 AS build
COPY . /app       # Any code change invalidates everything
RUN mvn package   # Re-downloads all dependencies every time

# CORRECT: resolve dependencies first.
COPY pom.xml /app/
RUN mvn dependency:resolve   # Cached
COPY src/ /app/src/
RUN mvn package              # Only this re-runs

What should a senior engineer know?

  • You can have more than two stages: a test stage, a build stage, and a runtime stage. Use --target to build just part of the pipeline.
  • Distroless is the strongest security posture but removes the shell, making kubectl exec debugging harder.
  • Combine multi-stage with jlink for extreme size reduction, or with GraalVM native-image to eliminate the JVM entirely.
  • Because the final image contains only the artifact and runtime, scanning it is cleaner and auditors can prove exactly what ships.

Visual Overview

DIAGRAM
  Single-stage:
  ┌────────────────────────────────┐
  │  Maven + JDK + source + caches │
  │  + app.jar                     │
  │  Size: ~1.2 GB                 │  ← Ships everything
  └────────────────────────────────┘

  Multi-stage:
  ┌────────────────────────────────┐
  │  Stage 1: BUILD                │
  │  Maven + JDK + source + caches │
  │  → produces app.jar            │   ← Discarded
  └────────────┬───────────────────┘
               │ COPY --from=build app.jar
               ▼
  ┌────────────────────────────────┐
  │  Stage 2: RUNTIME              │
  │  JRE + app.jar                 │
  │  Size: ~250 MB                 │  ← Ships only this
  └────────────────────────────────┘

Key Takeaway

A multi-stage Docker build uses multiple FROM instructions in one Dockerfile. An early stage runs on a heavy base with the full build toolchain and produces the application artifact. A later stage starts from a slim runtime base and copies only the finished artifact with COPY --from=build. Because only the final stage becomes the shipped image, everything in the build stages is discarded.

The size reduction is dramatic. A single-stage Java image on a Maven + JDK base can be over a gigabyte. The multi-stage equivalent on a slim JRE base is typically 200-300 MB, and on a distroless base even smaller. Smaller images pull faster, start faster, cost less to store, and present a smaller attack surface because there is no compiler or shell for an attacker to abuse.

Layer caching still applies within stages. Copying the dependency manifest and resolving dependencies before copying source means a code change only rebuilds the cheap top layers. For the strongest security posture, combine multi-stage with distroless or jlink for a minimal runtime.

Remember one sentence: build in the heavy workshop, ship only the finished product in a light package.

---

Share
Use ← → arrow keys to navigate