How Multi-Stage Docker Builds Reduce Image Size by 80%
Multi-stage Docker builds eliminate build dependencies from production images, cutting sizes from gigabytes to megabytes. Here's the complete guide with...
The 1.2 GB Container Problem
You write a 200-line Node.js API. You build a Docker image. It's 1.2 GB.
Docker Compose defines your entire application stack in a single YAML file.
Inside that image: GCC, Python build tools, node-gyp, thousands of header files, development dependencies, the entire npm cache, and your actual application code — about 15 MB of it.
This isn't hypothetical. It's the default outcome when you use a standard Dockerfile with a single FROM instruction. Every RUN command that installs build tools, every npm install that pulls devDependencies, every apt-get that adds compilation libraries — they all become permanent layers in your production image.
Multi-stage builds fix this completely. The same application ships as a 150 MB image. Same functionality, same runtime behavior, 80% smaller.
How Multi-Stage Builds Work
A multi-stage Dockerfile uses multiple FROM instructions. Each FROM starts a new build stage with a fresh filesystem. The critical insight: you can COPY --from= a previous stage, selectively pulling only the artifacts you need into the final image.
# Stage 1: Build environment (heavy, temporary)
FROM node:20-bookworm AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production environment (minimal, permanent)
FROM node:20-bookworm-slim AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]
Stage 1 has everything needed to build: full Node.js, npm, build tools, devDependencies. Stage 2 has only what's needed to run: a slim Node.js runtime, production code, and production dependencies.
The build tools, source code, devDependencies, and npm cache from Stage 1 never make it into the final image. They exist only during the build process.
Real-World Examples
Node.js / TypeScript API
# ---- Build Stage ----
FROM node:20-bookworm AS builder
WORKDIR /app
# Install dependencies first (better layer caching)
COPY package.json package-lock.json ./
RUN npm ci --include=dev
# Copy source and build
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Prune devDependencies
RUN npm prune --production
# ---- Production Stage ----
FROM node:20-bookworm-slim
WORKDIR /app
# Non-root user for security
RUN groupadd -r appuser && useradd -r -g appuser appuser
# Copy only production artifacts
COPY --from=builder --chown=appuser:appuser /app/dist ./dist
COPY --from=builder --chown=appuser:appuser /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appuser /app/package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
CMD ["node", "dist/index.js"]
Size comparison:
| Stage | Image | Size |
|---|---|---|
| Single-stage (node:20) | Everything included | 1.1 GB |
| Multi-stage (node:20-slim) | Production only | 210 MB |
| Multi-stage (distroless) | Minimal runtime | 130 MB |
Key techniques used:
npm ci --include=devin build stage for reproducible installsnpm prune --productionremoves devDependencies before copying to productionnode:20-bookworm-slimis 200 MB smaller than the full image- Non-root user for container security
- Health check for orchestrator integration
Go Service
Go produces static binaries, making multi-stage builds even more effective:
# ---- Build Stage ----
FROM golang:1.22-bookworm AS builder
WORKDIR /app
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Build static binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags='-w -s -extldflags "-static"' \
-o /app/server ./cmd/server
# ---- Production Stage ----
FROM scratch
# Copy CA certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy the static binary
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Size comparison:
Get more insights on DevOps
Join 2,000+ engineers who get our weekly deep-dives. No spam, unsubscribe anytime.
| Stage | Image | Size |
|---|---|---|
| Single-stage (golang:1.22) | Full Go toolchain | 850 MB |
| Multi-stage (alpine) | Alpine + binary | 25 MB |
| Multi-stage (scratch) | Just the binary | 12 MB |
The scratch base image is literally empty — zero bytes. The final image contains only your compiled binary and CA certificates. From 850 MB to 12 MB is a 98.6% reduction.
Build flags explained:
CGO_ENABLED=0: Disable C bindings for a pure Go binary-ldflags='-w -s': Strip debug symbols and DWARF tables-extldflags "-static": Ensure fully static linking
Rust Service
# ---- Build Stage ----
FROM rust:1.76-bookworm AS builder
WORKDIR /app
# Cache dependencies with a dummy build
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs
RUN cargo build --release && rm -rf src target/release/deps/myapp*
# Build the actual application
COPY src/ ./src/
RUN cargo build --release
# ---- Production Stage ----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/myapp /usr/local/bin/
EXPOSE 3000
CMD ["myapp"]
Size comparison:
| Stage | Image | Size |
|---|---|---|
| Single-stage (rust:1.76) | Full toolchain | 1.4 GB |
| Multi-stage (debian-slim) | Runtime + binary | 85 MB |
| Multi-stage (scratch with musl) | Just the binary | 8 MB |
The dummy build trick (echo 'fn main() {}' > src/main.rs) caches dependency compilation. When your source code changes, only your code recompiles — not all 200 crate dependencies.
Python (FastAPI)
Python is trickier because it's interpreted, but multi-stage builds still help significantly:
# ---- Build Stage ----
FROM python:3.12-bookworm AS builder
WORKDIR /app
# Install build dependencies for compiled packages
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev && rm -rf /var/lib/apt/lists/*
# Create virtual environment and install dependencies
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# ---- Production Stage ----
FROM python:3.12-slim-bookworm
WORKDIR /app
# Install only runtime libraries (no gcc, no headers)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 && rm -rf /var/lib/apt/lists/*
# Copy the virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy application code
COPY app/ ./app/
RUN useradd -r appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Size comparison:
| Stage | Image | Size |
|---|---|---|
| Single-stage (python:3.12) | Full Python + build tools | 1.0 GB |
| Multi-stage (python:3.12-slim) | Slim Python + venv | 180 MB |
The key insight: copy the entire virtual environment (/opt/venv) from the build stage. The build stage has GCC and header files for compiling C extensions (psycopg2, numpy, etc.), but the production stage only needs the compiled .so files and pure Python packages.
Java (Spring Boot)
# ---- Build Stage ----
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /app
COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts ./
RUN ./gradlew dependencies --no-daemon
COPY src/ ./src/
RUN ./gradlew bootJar --no-daemon
# ---- Extract layers for better caching ----
FROM eclipse-temurin:21-jdk-jammy AS extractor
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# ---- Production Stage ----
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
This uses three stages: build, extract layers, and production. Spring Boot's layertools extract dependencies into separate directories, enabling better Docker layer caching — library dependencies change less often than application code.
Advanced Techniques
Build Arguments for Conditional Stages
ARG BUILD_ENV=production
# Base build
FROM node:20-bookworm AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Development stage (includes devDeps, source maps, hot reload)
FROM base AS development
RUN npm install --include=dev
CMD ["npm", "run", "dev"]
# Test stage (runs tests then exits)
FROM base AS test
RUN npm install --include=dev
RUN npm test
# Production build
FROM base AS build
RUN npm run build
RUN npm prune --production
# Production image
FROM node:20-bookworm-slim AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
CMD ["node", "dist/index.js"]
Build specific stages with --target:
# Development
docker build --target development -t myapp:dev .
# Run tests (fails if tests fail)
docker build --target test -t myapp:test .
# Production
docker build --target production -t myapp:prod .
External Images as Stages
You can copy from any image, not just previous stages:
FROM alpine:3.19
# Copy a specific binary from an external image
COPY --from=docker.io/bitnami/kubectl:1.29 /opt/bitnami/kubectl/bin/kubectl /usr/local/bin/
COPY --from=docker.io/aquasec/trivy:latest /usr/local/bin/trivy /usr/local/bin/
# Your application
COPY --from=builder /app/deploy.sh /usr/local/bin/
CMD ["deploy.sh"]
This creates deployment tooling images without installing anything from package managers.
Container orchestration distributes workloads across multiple nodes for resilience and scale.
BuildKit Cache Mounts
Docker BuildKit enables cache mounts that persist across builds:
# syntax=docker/dockerfile:1
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
# Cache the Go module download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
# Cache the Go build cache
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /app/server ./cmd/server
FROM scratch
COPY --from=builder /app/server /server
CMD ["/server"]
Cache mounts don't become image layers — they persist on the build host between builds. This dramatically speeds up rebuilds without increasing image size.
CI/CD Integration
GitHub Actions
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
target: production
The cache-from and cache-to with type=gha uses GitHub Actions' cache backend for Docker layer caching. Combined with multi-stage builds, rebuilds that only change application code complete in seconds.
Gitea Actions / Self-Hosted CI
name: Build
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build production image
run: |
docker build \
--target production \
--cache-from myregistry.com/myapp:cache \
-t myregistry.com/myapp:${{ github.sha }} \
-t myregistry.com/myapp:latest .
- name: Run tests in container
run: |
docker build --target test .
- name: Push
run: |
docker push myregistry.com/myapp:${{ github.sha }}
docker push myregistry.com/myapp:latest
Security Benefits
Multi-stage builds aren't just about size. They're a security practice:
Smaller attack surface: No GCC, no make, no package managers in production. Fewer binaries = fewer potential exploits.
No build secrets in production: Build-time secrets (npm tokens, private registry credentials) exist only in the build stage. They never make it to the final image.
Free Resource
CI/CD Pipeline Blueprint
Our battle-tested pipeline template covering build, test, security scan, staging, and zero-downtime deployment stages.
# Build stage — secret is available here only
FROM node:20 AS builder
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
# Production — no trace of the secret
FROM node:20-slim
COPY --from=builder /app/node_modules ./node_modules
Vulnerability reduction: Scanning a 1.2 GB image with Trivy might flag 200+ CVEs in build tools you don't need. A 150 MB slim image might have 15. Fewer packages = fewer CVEs to triage.
Immutable production images: The production stage contains exactly what's needed. No package managers to install additional software. No compilers to build exploit tools.
Common Mistakes
Mistake 1: Copying Too Much
# Bad: copies everything including node_modules from host
COPY . .
# Good: use .dockerignore
# .dockerignore:
# node_modules
# .git
# *.md
# .env
# docker-compose*.yml
Mistake 2: Not Leveraging Layer Caching
# Bad: any source change invalidates npm install cache
COPY . .
RUN npm ci
# Good: dependency install is cached unless package.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Mistake 3: Using :latest in Production
# Bad: unpredictable, breaks reproducibility
FROM node:latest
# Good: pinned version, reproducible builds
FROM node:20.11.1-bookworm-slim
Mistake 4: Running as Root
# Bad: container runs as root by default
CMD ["node", "server.js"]
# Good: dedicated non-root user
RUN useradd -r appuser
USER appuser
CMD ["node", "server.js"]
Measuring the Impact
Use docker images and docker history to analyze your images:
# Compare sizes
docker images | grep myapp
myapp single-stage abc123 1.2GB
myapp multi-stage def456 150MB
# See layer breakdown
docker history myapp:multi-stage
# Scan for vulnerabilities
trivy image myapp:single-stage # 247 CVEs
trivy image myapp:multi-stage # 18 CVEs
# Analyze with dive (interactive layer explorer)
dive myapp:multi-stage
Docker Compose brings up your entire stack with a single command.
The Bottom Line
Multi-stage Docker builds are the single highest-impact optimization for container-based deployments. They reduce image sizes by 80%+, cut vulnerability counts by 90%+, speed up deployments (smaller images = faster pulls), and eliminate entire categories of security risks.
The pattern is simple: build in a heavy image, run in a light one. Every Dockerfile you write should use multi-stage builds. There's no reason not to — the build time impact is negligible, and the benefits are substantial.
If your production containers are over 500 MB, multi-stage builds are the first thing to fix.
Related Service
Platform Engineering
From CI/CD pipelines to service meshes, we create golden paths for your developers.
Need help with devops?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.
We Will Build You a Demo Site — For Free
Like it? Pay us. Do not like it? Walk away, zero complaints. You will spend way less than hiring developers or any agency.
No spam. No contracts. Just a free demo.