Multi-Stage Builds
Multi-stage builds are a Docker feature that allows you to use multiple FROM statements in a single Dockerfile. This separates build-time dependencies from runtime dependencies, resulting in dramatically smaller, more secure production images—often reducing image size by 80-95%.
Multi-stage builds allow you to use multiple FROM statements in a single Dockerfile. Each FROM begins a new stage. You can copy artifacts from earlier stages into later stages, leaving behind everything that isn't needed in the final image. This solves the classic Dockerfile dilemma: use a large image with build tools (compilers, SDKs, npm, etc.) to build your application, then copy only the compiled artifacts into a minimal runtime image.
Before multi-stage builds, developers often used complex shell scripts to build, then copy artifacts, or maintained separate Dockerfiles for development and production. Multi-stage builds solve this elegantly—everything stays in one file, and the final image contains only what's necessary to run the application.
Stage 1: Builder
┌─────────────────────────────┐
│ FROM golang:1.21 (1.2GB) │
│ - Go compiler │
│ - Full build tools │
│ - Source code │
│ - Compile myapp │
└─────────────┬───────────────┘
│ COPY --from=builder
↓
Stage 2: Runtime (Final)
┌─────────────────────────────┐
│ FROM alpine:latest (5MB) │
│ - Only compiled binary │
│ - No compilers │
│ - No source code │
│ - Final image: ~15MB │
└─────────────────────────────┘
Before multi-stage builds, Dockerfiles often looked like this: using a full SDK base image, installing build tools, compiling the application, and then—all those build tools and intermediate files stayed in the final image. The image contained compilers, package managers, source code, and debug symbols—none of which are needed to run the application. This resulted in huge images (1GB+), slower deployments, larger attack surfaces, and higher storage costs.
# Without multi-stage builds (BAD)
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install # Installs devDependencies too
COPY . .
RUN npm run build # Build tools stay in image
EXPOSE 3000
CMD ["npm", "start"]
# Result: 900MB+ image containing compilers, source code, dev dependencies
The syntax is simple: use multiple FROM statements. Each stage can have a name (using AS name). To copy files from a previous stage, use COPY --from=stage_name. Only the last stage's contents are included in the final image—all previous stages are discarded.
# Multi-stage build template
# Stage 1: Build (named "builder")
FROM node:18-alpine AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Stage 2: Production (final stage)
FROM node:18-alpine
WORKDIR /app
# Copy only what we need from builder
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules ./node_modules
CMD ["node", "dist/server.js"]
# The builder stage is discarded; only final stage is in the image
# Multi-stage build for Go
# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Copy go mod files first (caching)
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Stage 2: Runtime (minimal)
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy binary from builder
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]
# Without multi-stage: ~1.2GB
# With multi-stage: ~15MB
# Reduction: 98.7%
# Multi-stage build for Node.js with build tools
# Stage 1: Build (includes dev dependencies for building)
FROM node:18-alpine AS builder
WORKDIR /build
# Copy package files
COPY package*.json ./
COPY yarn.lock ./
# Install ALL dependencies (including dev)
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
RUN npm prune --production # Remove dev dependencies
# Stage 2: Production (only what's needed to run)
FROM node:18-alpine
WORKDIR /app
# Copy production artifacts only
COPY --from=builder /build/package*.json ./
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
USER nodejs
CMD ["node", "dist/server.js"]
# Without multi-stage: ~900MB
# With multi-stage: ~150MB
# Reduction: 83%
# Multi-stage build for Python
# Stage 1: Build wheels
FROM python:3.11-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc python3-dev && \
rm -rf /var/lib/apt/lists/*
# Copy requirements and build wheels
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
# Stage 2: Runtime
FROM python:3.11-slim
WORKDIR /app
# Copy wheels from builder and install
COPY --from=builder /app/wheels /wheels
COPY --from=builder /app/requirements.txt .
RUN pip install --no-cache /wheels/*
# Copy application code
COPY . .
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["python", "app.py"]
# Without multi-stage: ~900MB
# With multi-stage: ~120MB
# Reduction: 86%
# Multi-stage build for Java with Maven
# Stage 1: Build with Maven
FROM maven:3.9-eclipse-temurin-17-alpine AS builder
WORKDIR /app
# Copy pom.xml first for caching
COPY pom.xml .
RUN mvn dependency:go-offline
# Copy source and build
COPY src ./src
RUN mvn package -DskipTests
# Stage 2: Runtime (JRE only, no build tools)
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# Copy JAR from builder
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# Without multi-stage: ~700MB (includes Maven, JDK)
# With multi-stage: ~120MB (only JRE + JAR)
# Reduction: 83%
Multiple Build Stages for Different Environments: You can have separate stages for development, testing, and production. This keeps your Dockerfile clean while supporting different use cases.
Parallel Build Stages: Docker builds stages in parallel when possible. If you have independent build stages, they'll run simultaneously, reducing total build time.
Stop at a Specific Stage: Use --target to build only up to a specific stage: docker build --target builder -t myapp:builder .
# Multiple stages for different environments
FROM node:18-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package*.json ./
RUN npm ci
FROM deps AS dev
COPY . .
CMD ["npm", "run", "dev"]
FROM deps AS builder
COPY . .
RUN npm run build
FROM nginx:alpine AS prod
COPY --from=builder /app/dist /usr/share/nginx/html
# Build different stages:
# docker build --target dev -t myapp:dev .
# docker build --target prod -t myapp:prod .
| Language/Framework | Without Multi-Stage | With Multi-Stage | Reduction |
|---|---|---|---|
| Go (static binary) | 1.2 GB (full SDK + build tools) | 15 MB (Alpine + binary) | 98.7% |
| Node.js (TypeScript) | 900 MB (includes devDependencies, source) | 150 MB (only built files, production deps) | 83% |
| Python (with C extensions) | 950 MB (includes build tools, compilers) | 180 MB (only runtime + compiled libraries) | 81% |
| Java (Spring Boot) | 700 MB (includes Maven, JDK) | 180 MB (JRE + JAR) | 74% |
| Rust/React combined | 2 GB (Node + Rust compilers)200 MB90%
Stages can be named with AS. This makes your Dockerfile more readable and allows you to reference specific stages by name. You can copy from any previous stage, not just the immediate previous one. You can also copy multiple times from different stages.
# Advanced copying between stages
FROM node:18-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM maven:3.9-eclipse-temurin AS backend-builder
WORKDIR /backend
COPY backend/pom.xml ./
RUN mvn dependency:go-offline
COPY backend/ ./
RUN mvn package
FROM nginx:alpine
# Copy frontend static files
COPY --from=frontend-builder /frontend/dist /usr/share/nginx/html
# Copy backend JAR (if needed for API)
COPY --from=backend-builder /backend/target/*.jar /app/app.jar
EXPOSE 80
Multi-stage builds improve security in several ways. First, they eliminate build tools, compilers, and package managers from production images. These tools are common attack vectors and aren't needed at runtime. Second, they reduce the attack surface—smaller images have fewer binaries and libraries that could contain vulnerabilities. Third, they exclude source code from the final image, protecting proprietary code from potential container escapes.
- Name your stages using
AS stage_namefor clarity and easier reference. - Copy only what you need from previous stages. Be specific with paths—avoid copying entire directories if you only need a subdirectory.
- Use minimal base images for the final stage (Alpine, Distroless, or slim variants).
- Order stages for caching - Put less frequently changed stages first, more frequently changed later.
- Use multi-stage even for simple apps - The benefits almost always outweigh the slight complexity increase.
- Test each stage independently using
--targetto verify builds at each step. - Clean up package caches in the builder stage before copying to final stage.
COPY --from=nginx:alpine /etc/nginx/nginx.conf /nginx.conf. This is useful for pulling configuration or binaries from official images.docker images and won't persist on disk. Only the final stage's layers are kept. However, the builder stage's layers are cached to speed up subsequent builds.docker build --target stage_name -t myapp:stage . This is useful for debugging or creating development images. For example, you can build the builder stage separately to inspect intermediate files.COPY --from instructions—are you copying entire directories? Are you copying node_modules when you only need dist/ and package.json? Also ensure you're using a minimal base image for the final stage.DOCKER_BUILDKIT=1 docker build ..Multi-stage builds are essential for production-grade Docker images. They produce smaller, more secure images that deploy faster and reduce infrastructure costs.