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.
<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="200" rx="12" fill="#1a1a2e"/><rect x="30" y="30" width="100" height="130" rx="6" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="80" y="55" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="monospace">docker-</text><text x="80" y="70" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="monospace">compose</text><text x="80" y="85" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="monospace">.yml</text><line x1="45" y1="95" x2="115" y2="95" stroke="#3b82f6" stroke-width="0.5" opacity="0.5"/><rect x="50" y="105" width="50" height="8" rx="2" fill="#94a3b8" opacity="0.3"/><rect x="50" y="118" width="60" height="8" rx="2" fill="#94a3b8" opacity="0.3"/><rect x="50" y="131" width="40" height="8" rx="2" fill="#94a3b8" opacity="0.3"/><path d="M135,95 L175,95" stroke="#e2e8f0" stroke-width="2" marker-end="url(#arrow2)"/><defs><marker id="arrow2" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#e2e8f0"/></marker></defs><rect x="180" y="20" width="130" height="35" rx="6" fill="#6366f1" opacity="0.85"/><text x="245" y="42" text-anchor="middle" fill="#ffffff" font-size="11" font-family="system-ui">Web App</text><rect x="180" y="62" width="130" height="35" rx="6" fill="#a855f7" opacity="0.85"/><text x="245" y="84" text-anchor="middle" fill="#ffffff" font-size="11" font-family="system-ui">API Server</text><rect x="180" y="104" width="130" height="35" rx="6" fill="#2dd4bf" opacity="0.85"/><text x="245" y="126" text-anchor="middle" fill="#1a1a2e" font-size="11" font-family="system-ui">Database</text><rect x="180" y="146" width="130" height="35" rx="6" fill="#f59e0b" opacity="0.85"/><text x="245" y="168" text-anchor="middle" fill="#1a1a2e" font-size="11" font-family="system-ui">Cache</text><rect x="370" y="40" width="200" height="130" rx="8" fill="none" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="5,4"/><text x="470" y="62" text-anchor="middle" fill="#e2e8f0" font-size="10" font-family="system-ui">Docker Network</text><line x1="310" y1="37" x2="390" y2="80" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><line x1="310" y1="79" x2="390" y2="100" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><line x1="310" y1="121" x2="390" y2="120" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><line x1="310" y1="163" x2="390" y2="140" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><circle cx="400" cy="80" r="5" fill="#6366f1"/><circle cx="400" cy="100" r="5" fill="#a855f7"/><circle cx="400" cy="120" r="5" fill="#2dd4bf"/><circle cx="400" cy="140" r="5" fill="#f59e0b"/><text x="470" y="85" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:3000</text><text x="470" y="105" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:8080</text><text x="470" y="125" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:5432</text><text x="470" y="145" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:6379</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Docker Compose defines your entire application stack in a single YAML file.</p></div>
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:
|-------|-------|------|
Key techniques used:
npm ci --include=dev in build stage for reproducible installsnpm prune --production removes devDependencies before copying to productionnode:20-bookworm-slim is 200 MB smaller than the full imageGo 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:
|-------|-------|------|
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 linkingRust 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:
|-------|-------|------|
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:
|-------|-------|------|
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.
<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 220" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="220" rx="12" fill="#1a1a2e"/><rect x="200" y="15" width="200" height="40" rx="8" fill="#6366f1"/><text x="300" y="40" text-anchor="middle" fill="#ffffff" font-size="13" font-family="system-ui" font-weight="bold">Orchestrator</text><line x1="250" y1="55" x2="100" y2="90" stroke="#e2e8f0" stroke-width="1.5" stroke-dasharray="4,3"/><line x1="300" y1="55" x2="300" y2="90" stroke="#e2e8f0" stroke-width="1.5" stroke-dasharray="4,3"/><line x1="350" y1="55" x2="500" y2="90" stroke="#e2e8f0" stroke-width="1.5" stroke-dasharray="4,3"/><rect x="40" y="90" width="120" height="110" rx="8" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="100" y="110" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">Node 1</text><rect x="55" y="120" width="90" height="25" rx="4" fill="#6366f1" opacity="0.7"/><text x="100" y="137" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container A</text><rect x="55" y="150" width="90" height="25" rx="4" fill="#a855f7" opacity="0.7"/><text x="100" y="167" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container B</text><rect x="240" y="90" width="120" height="110" rx="8" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="300" y="110" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">Node 2</text><rect x="255" y="120" width="90" height="25" rx="4" fill="#2dd4bf" opacity="0.7"/><text x="300" y="137" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Container C</text><rect x="255" y="150" width="90" height="25" rx="4" fill="#6366f1" opacity="0.7"/><text x="300" y="167" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container A</text><rect x="440" y="90" width="120" height="110" rx="8" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="500" y="110" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">Node 3</text><rect x="455" y="120" width="90" height="25" rx="4" fill="#a855f7" opacity="0.7"/><text x="500" y="137" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container B</text><rect x="455" y="150" width="90" height="25" rx="4" fill="#f59e0b" opacity="0.7"/><text x="500" y="167" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Container D</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Container orchestration distributes workloads across multiple nodes for resilience and scale.</p></div>
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: productionThe 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:latestSecurity Benefits
Multi-stage builds aren't just about size. They're a security practice:
1. Smaller attack surface: No GCC, no make, no package managers in production. Fewer binaries = fewer potential exploits.
2. 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.
# 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_modules3. 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.
4. 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*.ymlMistake 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-slimMistake 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<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 190" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="190" rx="12" fill="#0d1117"/><rect x="0" y="0" width="600" height="28" rx="12" fill="#1c2333"/><rect x="0" y="12" width="600" height="16" fill="#1c2333"/><circle cx="18" cy="14" r="5" fill="#ef4444"/><circle cx="34" cy="14" r="5" fill="#f59e0b"/><circle cx="50" cy="14" r="5" fill="#2dd4bf"/><text x="300" y="18" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="monospace">Terminal</text><text x="20" y="50" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><text x="35" y="50" fill="#e2e8f0" font-size="11" font-family="monospace">docker compose up -d</text><text x="20" y="70" fill="#94a3b8" font-size="11" font-family="monospace">[+] Running 5/5</text><text x="20" y="88" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="88" fill="#94a3b8" font-size="10" font-family="monospace">Network app_default Created</text><text x="20" y="106" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="106" fill="#94a3b8" font-size="10" font-family="monospace">Container web Started</text><text x="20" y="124" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="124" fill="#94a3b8" font-size="10" font-family="monospace">Container api Started</text><text x="20" y="142" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="142" fill="#94a3b8" font-size="10" font-family="monospace">Container db Started</text><text x="20" y="165" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><rect x="35" y="155" width="8" height="14" fill="#e2e8f0" opacity="0.7"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Docker Compose brings up your entire stack with a single command.</p></div>
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.
Need help with devops?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.