Multi-Stage Docker Builds
Multi-stage builds are a powerful Docker feature that optimizes final image size, enhances security, and simplifies the build process. In this article, we’ll explore how they work, how to use them, and the benefits they provide.
Why Multi-Stage Builds Are Necessary
Previously, developers faced a dilemma between two problems:
- Bulky images: Building and running an application in a single image included build tools (compilers, packages) unnecessary for production.
- Complex workflows: Required multiple Dockerfiles or external scripts to separate build and runtime stages.
Multi-stage builds solve these issues by allowing you to:
- Create intermediate images for application builds.
- Copy only final artifacts (binaries, JAR files) into the production image.
- Eliminate unnecessary dependencies, reducing image size by 90% or more.
- Improve security by removing tools attackers could exploit.
How It Works: Basic Example
Multi-stage builds rely on multiple FROM instructions in a single Dockerfile, each starting a new build stage. Consider this Go application example:
# syntax=docker/dockerfile:1
FROM golang:1.24 AS build
WORKDIR /src
COPY <<EOF ./main.go
package main
import "fmt"
func main() {
fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go
FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]
What happens:
1. build stage:
- Uses the golang:1.24 image with preinstalled Go SDK.
- Copies source code and compiles the binary to /bin/hello.
2. Final stage:
- Uses the empty scratch base image (zero size).
- Copies only the compiled binary from the build stage.
- Result: An image just a few kilobytes in size!
Build command:
docker build -t hello-app .
Naming Stages: Why and How?
Stages are numbered by default (0, 1, 2...), but meaningful names via AS are preferable:
FROM maven:3.17-jdk-17 AS builder
...
FROM eclipse-temurin:17-jre-alpine
COPY --from=builder /app/target/app.jar /app.jar
Benefits:
- Improved Dockerfile readability.
- Resilience to refactoring: Reordering stages won’t break the build.
Stopping at a Specific Stage
Sometimes you need an intermediate image instead of the final one—for debugging or testing. Use the --target flag:
docker build --target builder -t app-builder .
Use cases:
- Debugging build-stage issues.
- Creating test images with extra utilities.
- Separating development and production images.
External Images as Artifact Sources
You can copy files not only from your own stages but also from any public or private image:
COPY --from=nginx:1.25 /etc/nginx/nginx.conf /app/nginx.conf
Docker automatically downloads the image if needed. This is useful for:
- Extracting configs from official images.
- Building using libraries from specialized images.
BuildKit vs. Legacy Builder: Which to Choose?
BuildKit (enabled by default in newer Docker versions) offers critical improvements:
| Criterion | Legacy Builder | BuildKit |
|---|---|---|
| Speed | Sequential execution | Parallel execution |
| Caching | Basic | Advanced (dependency-aware) |
| Efficiency | Builds all stages | Builds only required stages |
| Security | No built-in secrets | Secrets and SSH support |
Example:
# BuildKit automatically skips stages irrelevant to the target
DOCKER_BUILDKIT=1 docker build --target stage2 -t app .
Distroless Images: Security as a Priority
For the final image, distroless images from Google are recommended. They contain only:
- The runtime (e.g., JRE for Java apps).
- Minimal dependencies required to run.
Java application example:
FROM maven:3.9-eclipse-temurin-17 AS build
...
FROM gcr.io/distroless/java17-debian12
COPY --from=build /app/target/app.jar /app.jar
CMD ["app.jar"]
Advantages:
- No shell (bash/sh) or package managers—attackers have nothing to exploit.
- Image size is 5–10x smaller than Ubuntu/Alpine equivalents.
Best Practices
- Minimize layers in the final image:
# Bad: Each RUN creates a layer
RUN apt-get update
RUN apt-get install -y build-essential
# Good: Chain commands
RUN apt-get update && apt-get install -y build-essential \
&& rm -rf /var/lib/apt/lists/*
-
Use official images as base stages.
-
Scan the final image for vulnerabilities:
docker scan hello-app
- Pin exact image versions:
FROM golang:1.24.0-bullseye # Avoid golang:latest
Real-World Case: Java Web Application
# Build stage
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn -DskipTests package
# Final stage
FROM eclipse-temurin:17-jre-alpine
COPY --from=build /app/target/myapp-1.0.jar /app.jar
EXPOSE 8080
CMD ["java", "-jar", "/app.jar"]
Result:
- Build image: ~500 MB (includes Maven, JDK, source code).
- Final image: ~180 MB (JRE + JAR file only).
Conclusion
Multi-stage builds are essential for modern DevOps practices. They enable you to:
- Reduce image download times in cloud environments.
- Minimize attack surface.
- Simplify Dockerfile maintenance.
Start using them today:
- Separate build and runtime stages in your Dockerfiles.
- Enable BuildKit by adding to ~/.docker/config.json:
{ "features": { "buildkit": true } }
- Experiment with distroless images for security-critical services.
Remember: A smaller image means faster deployments, lower storage costs, and enhanced application security.