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.

published: reading time: 14 min read

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

ImageSizeWhat You Get
node:20~1.2GBFull Node.js with npm, shell, build tools
node:20-slim~150MBNode.js with npm, minimal packages
node:20-alpine~130MBNode.js with apk, musl libc
node:20-distroless~80MBNode.js only, no shell
scratch0MBNothing, 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:

  1. All dependencies installed in builder stage (including devDependencies for build)
  2. Production stage installs only production dependencies
  3. npm cache is cleared after install
  4. Built files are copied with correct ownership
  5. Non-root user for security
  6. 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 ImageLayer SizeFinal Image SizeNotes
node:20 (monolithic)~1.2GB1.2GBFull build tools included
node:20-slim~150MB150MBNo compiler, smaller libc
node:20-alpine~130MB130MBmusl libc, compact package manager
node:20-distroless~80MB80MBNo shell, minimal attack surface
Multi-stage (node:20 + alpine)builder ~1GB, runtime ~15MB layer~145MB~90% size reduction

Typical Build Time Differences

ScenarioFirst BuildCached Build (code only)Cached Build (deps changed)
Monolithic node:203m 20s2m 45s3m 15s
Multi-stage3m 40s25s2m 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 ImageRuntime ImageIssue
node:20 (glibc)node:20-alpine (musl)Native modules may fail at runtime
golang:alpinescratchStatic 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 through ARG. 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 like node:20-alpine can 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:

  1. Use a full build image as the first stage
  2. Build your application
  3. Copy only what you need to run into a minimal runtime image
  4. 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 #docker-compose #devops

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.

#docker #containers #devops

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.

#cicd #devops #artifacts