Multi-Stage Builds: Minimal Production Docker Images
Learn how multi-stage builds dramatically reduce image sizes by separating build-time and runtime dependencies, resulting in faster deployments and smaller attack surfaces.
Multi-Stage Builds: Minimal Production Docker Images
Multi-stage builds solve a real problem: before them, you had to choose between build convenience and production image cleanliness. Now you get both.
This tutorial walks through the problem multi-stage builds solve, the mechanics of how they work, and practical examples for common languages and frameworks.
When to Use Multi-Stage Builds / When Not To
Multi-stage builds are not always the right choice. Understanding when they help—and when they add unnecessary complexity—prevents over-engineering.
Use multi-stage builds when:
- Your application requires compilation or transformation steps (build tools, compilers, dependency resolution)
- You want to keep production images minimal and free of build artifacts
- Security and attack surface reduction are priorities
- You deploy frequently and image size affects deployment speed
- You need to run as non-root but build as root
- Your final image runs in constrained environments (Kubernetes with resource limits, edge devices)
Skip multi-stage builds when:
- Your application is a simple script or single file that runs directly with an interpreter
- You use a pre-built image with all dependencies already included
- Build time is not a concern and image size does not matter (internal tools, one-off scripts)
- You are prototyping and simplicity matters more than optimization
For example, a simple Python script that only uses the standard library can run directly from python:alpine. A Node.js API with TypeScript compilation, bundling, and multiple npm dependencies benefits greatly from multi-stage builds.
The Problem with Monolithic Images
Traditional Dockerfiles include everything needed to build and run your application in a single image:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
This Dockerfile works. But the resulting image contains the entire Node.js build toolchain, your source code, all npm packages including devDependencies, and the build output. The image might be 1.2GB when your production runtime only needs 150MB.
The problems compound as you iterate:
- Slow deployments: Pushing and pulling large images takes forever
- Large attack surface: Your production image contains compilers, shell access, build tools
- Security vulnerabilities: The build tools and dependencies may have CVEs that do not affect runtime
- Cache inefficiency: Changing a line of code invalidates the build cache for everything
Multi-Stage Build Anatomy
Multi-stage builds use multiple FROM statements. Each FROM starts a fresh build stage. You copy only what you need from each stage into the final image.
# Stage 1: Build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
# Copy only the built artifacts and production deps
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
USER node
CMD ["node", "dist/server.js"]
The AS builder names the first stage so you can reference it later. The --from=builder flag tells Docker to copy from that stage’s filesystem, discarding everything else.
Two-Stage Build Flow
flow TB
subgraph Builder["Builder Stage (node:20)"]
B1[Copy Source Code]
B2[Install Dependencies]
B3[Compile / Build]
B4[Build Output Ready]
B1 --> B2 --> B3 --> B4
end
subgraph Runtime["Runtime Stage (node:20-alpine)"]
R1[Fresh Minimal Base]
R2[Copy Build Artifacts]
R3[Install Production Dependencies]
R4[Set Non-Root User]
R1 --> R2 --> R3 --> R4
end
Builder --> |COPY --from=builder| Runtime
Runtime --> FinalImage["Final Image<br/>~130MB"]
What Gets Discarded
The final image contains only:
- The Alpine-based runtime (7MB vs 1.2GB for the full Node image)
- The built application code (
dist/) - The production node_modules
- No compiler, no source code, no build tools, no shell
Choosing Base Images
The base image you choose for your runtime stage sets the foundation for your production image size.
Base Image Options
| Image | Size | What You Get |
|---|---|---|
node:20 | ~1.2GB | Full Node.js with npm, shell, build tools |
node:20-slim | ~150MB | Node.js with npm, minimal packages |
node:20-alpine | ~130MB | Node.js with apk, musl libc |
node:20-distroless | ~80MB | Node.js only, no shell |
scratch | 0MB | Nothing, you provide everything |
For most applications, node:20-alpine hits the sweet spot: small size, musl libc compatibility, and a package manager for emergencies.
When to Use Distroless
Distroless images contain only the runtime and application. No shell, no package manager, no ability to exec into the container.
FROM node:20 AS builder
# ... build steps ...
FROM gcr.io/distroless/nodejs20-debian11
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
USER nonroot
CMD ["dist/server.js"]
The tradeoff: you cannot debug by exec’ing into the container. Build comprehensive logging and monitoring into your application before going this route.
Copying Artifacts Between Stages
The COPY --from= instruction supports several ways to reference source content:
Copy from a Named Stage
COPY --from=builder /app/dist ./dist
Copy from a Numbered Stage
Stages are numbered starting at 0:
# Stage 0
FROM node:20 AS builder
# ...
# Stage 1
FROM node:20-alpine AS production
# ...
# Stage 2
FROM nginx:alpine
COPY --from=1 /app/dist ./usr/share/nginx/html
Copy from an External Image
You do not need to build an image in the same Dockerfile to copy from it:
# Extract just the binary from a go image
COPY --from=golang:1.22 /usr/local/bin/hello /usr/local/bin/
This is useful when you want to use an external tool during build without carrying it into your final image.
Copy with Ownership Change
When copying from a builder stage running as root to a production stage using a non-root user:
COPY --from=builder --chown=node:node /app/dist ./dist
This ensures the production user can read the files.
Real-World Examples
Node.js Application
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Install all dependencies (including devDependencies for build tools)
COPY package*.json ./
RUN npm ci
# Copy source and build
COPY src ./src
COPY public ./public
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Copy package files first for better cache
COPY package*.json ./
# Install production dependencies only
RUN npm ci --only=production && npm cache clean --force
# Copy built application from builder stage
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/public ./public
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 -G nodejs
USER nodejs
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
CMD ["node", "dist/server.js"]
Key optimizations in this Dockerfile:
- All dependencies installed in builder stage (including devDependencies for build)
- Production stage installs only
productiondependencies - npm cache is cleared after install
- Built files are copied with correct ownership
- Non-root user for security
- Health check for orchestrator integration
Go Application
Go compiles to a static binary, which makes it ideal for scratch-based images:
# Build stage
FROM golang:1.22-alpine AS builder
# Install git for go modules
RUN apk add --no-cache git
WORKDIR /app
# Copy go mod files first for dependency caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
# CGO_ENABLED=0 for static binary, no need for c libraries
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "-w -s" -o myapp .
# Production stage - just the binary
FROM alpine:3.19
WORKDIR /app
# Add CA certificates for HTTPS, create user
RUN apk add --no-cache ca-certificates && \
addgroup -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
# Copy binary from builder
COPY --from=builder /app/myapp .
# Switch to non-root user
USER appuser
# No ENTRYPOINT or CMD here - use explicit executable path
EXPOSE 8080
ENTRYPOINT ["./myapp"]
The final image is around 15MB: Alpine base plus the static Go binary. No Go runtime, no git, no source.
Python Application
Python applications typically need more runtime dependencies than Go, but multi-stage builds still help:
# Build stage
FROM python:3.12-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Production stage
FROM python:3.12-slim
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application code
COPY app ./app
COPY gunicorn.conf.py .
# Create non-root user
RUN useradd --create-home appuser && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
# Use gunicorn as the application server
CMD ["gunicorn", "--config", "gunicorn.conf.py", "app:app"]
Rust Application
Rust produces static binaries with some caveats around musl libc and OpenSSL:
# Build stage
FROM rust:1.77-alpine AS builder
# Install build dependencies
RUN apk add --no-cache \
musl-dev \
pkgconfig \
openssl-dev \
openssl-lib-static
WORKDIR /app
# Copy manifests first for dependency caching
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release && rm -rf src
# Copy actual source
COPY src ./src
COPY . .
RUN cargo build --release
# Production stage
FROM alpine:3.19
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache ca-certificates
# Copy binary
COPY --from=builder /app/target/release/myapp /usr/local/bin/
# Create user
RUN addgroup -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
USER appuser
EXPOSE 8080
CMD ["myapp"]
Common Pitfalls
Forgetting to Install Production Dependencies
If you copy the entire node_modules from builder, you include devDependencies. Your production image is larger and may have security vulnerabilities that do not affect production:
# Wrong: copies everything from builder
COPY --from=builder /app .
# Right: install production only in final stage
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules # Still includes devDeps
The correct approach for Node.js is to run npm ci --only=production in the production stage.
Copying Unnecessary Files
Be explicit about what you copy. Do not copy the entire working directory:
# Wrong
COPY --from=builder /app .
# Right - copy specific directories
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json .
Not Using the Correct Architecture for Build
If you build on an amd64 host but deploy to arm64 (or vice versa), use Docker buildx for cross-platform builds:
# Enable buildx
docker buildx create --use
# Build for multiple platforms
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag myapp:1.0.0 \
--push \
.
Buildx creates manifest lists so Docker automatically pulls the right image for each platform.
Performance Impact
Multi-stage builds affect both build time and deployment time.
Build Time
First build takes longer because you run all build steps. Subsequent builds use cache efficiently if you order instructions correctly.
Cached build stages also speed up parallel development: if only your source code changes (not dependencies), the dependency installation layer is cached.
Deployment Time
Image size directly affects:
- Push time: Network transfer to registry
- Pull time: Network transfer from registry to host
- Startup time: Image layers must be downloaded and extracted
A 1.2GB image might take 2 minutes to pull over a fast connection. A 150MB image takes 15 seconds.
For frequent deployments or auto-scaling scenarios, this difference is substantial.
Benchmark: Image Sizes for a Typical Node.js Application
| Base Image | Layer Size | Final Image Size | Notes |
|---|---|---|---|
node:20 (monolithic) | ~1.2GB | 1.2GB | Full build tools included |
node:20-slim | ~150MB | 150MB | No compiler, smaller libc |
node:20-alpine | ~130MB | 130MB | musl libc, compact package manager |
node:20-distroless | ~80MB | 80MB | No shell, minimal attack surface |
| Multi-stage (node:20 + alpine) | builder ~1GB, runtime ~15MB layer | ~145MB | ~90% size reduction |
Typical Build Time Differences
| Scenario | First Build | Cached Build (code only) | Cached Build (deps changed) |
|---|---|---|---|
| Monolithic node:20 | 3m 20s | 2m 45s | 3m 15s |
| Multi-stage | 3m 40s | 25s | 2m 50s |
Multi-stage builds add slight overhead on first build but dramatically speed up iterative development. When only source code changes, the cached dependency layer is reused and only the build step runs.
Production Failure Scenarios
Multi-stage builds introduce their own failure modes. Understanding these helps you debug issues when they arise.
Build Cache Invalidation Causing Full Rebuild
Docker caches each layer. When a layer changes, all subsequent layers are invalidated. When dependency versions in package.json change, the entire dependency installation layer is invalidated even if your code did not change.
Base image updates also invalidate the cache. If node:20-alpine is updated on Docker Hub, every layer built on top of it needs rebuilding.
Mitigation: Use docker build --no-cache periodically to force fresh builds, and consider using BuildKit with build-time content hashing.
Cross-Architecture Build Failures
Building on one architecture and running on another causes binary incompatibility. The error appears at runtime: exec format error: /myapp: cannot execute binary file.
Mitigation: Use Docker buildx for cross-platform builds:
docker buildx create --use
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:multiarch --push .
Binary Incompatibility Between Builder and Runtime
Alpine Linux uses musl libc instead of glibc. Native Node modules with C++ addons compiled against glibc fail at runtime on Alpine. When using Alpine as your runtime, either use Alpine for the builder too, or use node:<version>-slim (Debian-based).
| Builder Image | Runtime Image | Issue |
|---|---|---|
node:20 (glibc) | node:20-alpine (musl) | Native modules may fail at runtime |
golang:alpine | scratch | Static binaries work, but certificate paths differ |
Security Implications
Smaller images have smaller attack surfaces. An Alpine base image has far fewer installed packages than a full Ubuntu image. A distroless image has no shell at all.
But multi-stage builds also help even when using larger base images, because build tools and compilers are not in the final image:
# Scan the full image (everything in builder)
docker scan myapp:monolithic
# Scan the multi-stage image (only runtime)
docker scan myapp:multistage
The multi-stage image will show far fewer vulnerabilities because it simply does not include the vulnerable packages.
Security and Compliance Checklist
Use this checklist when deploying multi-stage builds to production environments:
-
Do not include secrets in build args: Build arguments (
ARG) are persisted in image layers and visible in image history. Never pass passwords, API keys, or tokens throughARG. Use Docker secrets for Swarm services or external secret injection at runtime.# Wrong — secret visible in image history ARG API_KEY=my-secret-key # Right — inject at runtime via environment ENV API_KEY=${API_KEY} -
Use specific image tags, not
latest: Floating tags likenode:20-alpinecan change. Pin to specific versions for reproducible builds. -
Scan final image only, not builder: Security scans should target the production runtime image. The builder stage contains build tools that may have vulnerabilities that do not affect runtime.
-
Run as non-root: Always create and switch to a non-root user in the final stage.
-
Read-only filesystem where possible: Combine with tmpfs mounts for writable space.
-
No shell access in production: Use distroless or scratch base images when exec access is not needed.
Conclusion
Multi-stage builds separate your build environment from your runtime environment. This lets you use full build toolchains during compilation while shipping minimal images to production.
The pattern is consistent across languages:
- Use a full build image as the first stage
- Build your application
- Copy only what you need to run into a minimal runtime image
- Run as non-root in the production stage
The build complexity overhead is minimal compared to the size and security wins. Every deployment gets faster as a bonus.
For deeper understanding of image optimization, see Container Images: Building, Optimizing, and Distributing. For orchestrating multi-container applications with multi-stage builds, continue to Docker Compose.
Quick Recap
- Multi-stage builds separate build environment from runtime environment
- Use full build images (node:20, golang:1.22) in the builder stage for complete toolchains
- Use minimal base images (alpine, distroless, scratch) in the production stage
- Copy only necessary artifacts: built output, production dependencies, config files
- Run the final stage as non-root for security
- Use Docker buildx for cross-platform builds
- Order Dockerfile instructions to maximize cache efficiency: dependencies first, source code last
- Test your multi-stage build locally before deploying to production
Category
Related Posts
Docker Compose: Orchestrating Multi-Container Applications
Define and run multi-container Docker applications using Docker Compose. From local development environments to complex microservice topologies.
Docker Fundamentals: From Images to Production Containers
Master Docker containers, images, Dockerfiles, docker-compose, volumes, and networking. A comprehensive guide for developers getting started with containerization.
Artifact Management: Build Caching, Provenance, and Retention
Manage CI/CD artifacts effectively—build caching for speed, provenance tracking for security, and retention policies for cost control.