What Is a Multi-Stage Build?
A technique that uses multiple FROM statements in a single Dockerfile to separate build stages. Build tools (compilers, build utilities, development dependencies) are used only during the build stage, and only files needed for execution are included in the final image, dramatically reducing image size.
As an everyday analogy, building a house requires cranes and cement mixers, but you don’t bring them when you move in. Multi-stage builds separate the construction equipment (build tools) from the finished house (executable files).
Single-Stage vs Multi-Stage
Let’s first look at the problem with single-stage builds.
# Single-stage build — build tools are included in the final image
FROM node:22
WORKDIR /app
# Install dependencies (including devDependencies)
COPY package*.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
# TypeScript source, devDependencies in node_modules,
# build tools, etc. are all included in the image
EXPOSE 3000
CMD ["node", "dist/server.js"]
# Result: image size ~1.2GB
# Unnecessary: TypeScript compiler, build tools, source files, devDependencies
Improved with a multi-stage build:
# === Multi-stage build — separate build and runtime environments ===
# Stage 1: Build stage (named: builder)
FROM node:22-alpine AS builder
WORKDIR /app
# Install dependencies (copy package.json first for cache optimization)
COPY package*.json ./
RUN npm ci
# Copy source and build TypeScript
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Reinstall production dependencies only
RUN npm ci --omit=dev
# Stage 2: Runtime stage (minimal image)
FROM node:22-alpine AS runner
WORKDIR /app
# Security: create a non-root user
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
# Copy only the necessary files from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
# Result: image size ~180MB (~85% reduction)
COPY --from=builder is the key. It selectively copies only the build artifacts from the previous stage (builder). Build tools, TypeScript source, and devDependencies are not included in the final image.
Language-Specific Multi-Stage Patterns
Go — Static Binary
Go produces static binaries, so no runtime is needed in the final image. Using scratch (empty image) or distroless creates extremely small images.
# Go multi-stage build
FROM golang:1.23-alpine AS builder
WORKDIR /app
# Download dependencies (cache optimization)
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
# CGO_ENABLED=0: remove C library dependency (static binary)
# -ldflags="-s -w": strip debug info (reduce binary size)
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server ./cmd/server
# Runtime stage: scratch (no OS, binary only)
FROM scratch
# Copy SSL certificates (needed for HTTPS requests)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy binary
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# Result: image size ~15MB (vs ~800MB for Go SDK image)
Python — Copy Only pip install Results
# Python multi-stage build
FROM python:3.12-slim AS builder
WORKDIR /app
# Create virtual environment and install dependencies
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
# Install build-essential for packages that need build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
&& pip install --no-cache-dir -r requirements.txt \
&& apt-get purge -y build-essential && apt-get autoremove -y
# Runtime stage
FROM python:3.12-slim AS runner
WORKDIR /app
# Copy only the virtual environment (exclude build tools)
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Non-root user
RUN useradd --system appuser
COPY . .
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app:create_app()", "--bind", "0.0.0.0:8000"]
Build Cache Optimization
Strategies for efficiently leveraging cache in multi-stage builds.
# Cache-optimized Dockerfile (Node.js example)
FROM node:22-alpine AS deps
WORKDIR /app
# Copy package.json first (preserve dependency cache when source changes)
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
# Copy node_modules from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Reinstall production dependencies only
RUN npm ci --omit=dev
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
The .dockerignore file also affects cache efficiency.
# .dockerignore — exclude unnecessary files from the build context
node_modules
dist
.git
.env
*.md
.vscode
coverage
Image Size Comparison
Image size comparisons measured from real projects.
# Check image sizes
docker images
# REPOSITORY TAG SIZE
# my-app single-stage 1.2GB
# my-app multi-stage 180MB
# my-go-app multi-stage 15MB
# Analyze image layers (check each layer's size)
docker history my-app:multi-stage
# IMAGE CREATED SIZE COMMENT
# a1b2c3d4 2 minutes ago 0B CMD ["node" "dist/server.js"]
# e5f6a7b8 2 minutes ago 0B EXPOSE 3000
# c9d0e1f2 2 minutes ago 0B USER appuser
# 12345678 2 minutes ago 45MB COPY dir:... in /app/node_modules
# 9abcdef0 2 minutes ago 2.5MB COPY dir:... in /app/dist
# Detailed layer analysis with dive (https://github.com/wagoodman/dive)
dive my-app:multi-stage
Building a Specific Stage Only
You can build only a specific stage for debugging or testing.
# Build only up to the builder stage (to inspect build artifacts)
docker build --target builder -t my-app:builder .
# Access the build stage container for debugging
docker run -it --rm my-app:builder sh
# Pass build arguments
docker build --build-arg NODE_ENV=production -t my-app:prod .
Practical Tips
- Use Alpine images: Simply switching the base image from
node:22(~350MB) tonode:22-alpine(~50MB) significantly reduces size. However, Alpine uses musl libc, which may cause compatibility issues with glibc-dependent packages. - Distroless images: Google’s distroless images (
gcr.io/distroless/nodejs22) are minimal images without even a shell. They’re great for security-critical production environments, but the downside is that debugging inside the container is difficult. - Optimize layer order: Copy things that change infrequently (dependency installation) first, and things that change frequently (source code) later to increase build cache hit rates.
- Clean up unnecessary files: Adding cleanup commands like
RUN rm -rf /var/cache/apk/* /tmp/*in the build stage reduces intermediate layer sizes. However, in multi-stage builds, this doesn’t affect the final image, so you only need to worry about the runner stage. - Leverage BuildKit: Activating BuildKit with
DOCKER_BUILDKIT=1 docker build .enables advanced optimizations like parallel builds and cache mounts (--mount=type=cache). It’s enabled by default in Docker 23.0 and above.