In the world of containerized applications, image size is not just a number—it is a metric that directly impacts deployment speed, storage costs, and attack surface area. For intermediate to advanced developers, the transition from single-stage to multi-stage builds is one of the most critical optimizations in a DevOps engineer's toolkit. This blog post explores the mechanics, benefits, and practical implementation of Docker multi-stage builds.
The Problem with Single-Stage Builds
Traditionally, building a Docker image involved combining the build environment and the runtime environment into a single layer. Consider a typical Go or Java application. To compile the source code, you need compilers (like gcc or javac), build tools (make, gradle), and potentially development libraries. These tools add significant weight to the final image.
Even if you strip these dependencies before running the application, the layers remain in the image history. This results in bloated images that take longer to push and pull from registries, slower startup times, and a larger potential attack surface for vulnerabilities. Multi-stage builds solve this by allowing you to use multiple FROM statements in your Dockerfile, copying artifacts from one stage to another, and discarding the unnecessary bloat.
How Multi-Stage Builds Work
Multi-stage builds divide the build process into distinct stages. Each FROM instruction starts a new stage. You can give stages names to make referencing them easier. The key concept is that only the final stage becomes the actual image that is pushed to your registry.
Let's look at a practical example using a simple Go application. The goal is to produce a tiny, static binary that runs in a minimal Alpine Linux base image.
Example: Optimizing a Go Application
# Stage 1: The Build Environment
FROM golang:1.21-alpine AS builder
# Install necessary build tools
RUN apk add --no-cache git
# Set the working directory
WORKDIR /app
# Copy go.mod and go.sum files for dependency caching
COPY go.mod go.sum ./
RUN go mod download
# Copy the source code
COPY . .
# Build the application
RUN go build -o myapp .
# Stage 2: The Runtime Environment
FROM alpine:3.18 AS final
# Install ca-certificates for HTTPS requests
RUN apk --no-cache add ca-certificates
# Create a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Copy the binary from the builder stage
COPY --from=builder /app/myapp /usr/local/bin/myapp
# Set ownership to the non-root user
RUN chown appuser:appgroup /usr/local/bin/myapp
# Switch to non-root user
USER appuser
# Expose port (if applicable)
EXPOSE 8080
# Run the application
CMD ["myapp"]
In this example, the builder stage uses a full-featured Go image to compile the code. The final stage uses a minimal Alpine image, copies only the compiled binary, and discards all the build tools and source code. The resulting image is drastically smaller—often less than 10MB compared to several hundred megabytes.
Best Practices and Advanced Techniques
Labeling Stages
Always label your stages using AS name. This makes your Dockerfile more readable and allows you to reference specific stages during the copy process using --from=name.
Caching Optimization
Place commands that change infrequently (like installing dependencies) earlier in the build process. This leverages Docker's layer caching, speeding up rebuilds when you modify source code but not dependencies.
Security Considerations
By using a minimal final image (like Alpine or Distroless), you reduce the number of installed packages and thus the number of potential vulnerabilities. Additionally, always run your application as a non-root user to limit the impact of container escapes.
Conclusion
Multi-stage builds are not just a convenience; they are a necessity for production-grade containers. They enable developers to decouple the build environment from the runtime environment, resulting in smaller, faster, and more secure images. By adopting this pattern, you streamline your CI/CD pipelines, reduce cloud storage costs, and improve the overall reliability of your infrastructure. Start refactoring your Dockerfiles today to reap the benefits of this powerful feature.