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...

T
TechSaaS Team
12 min read

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.ymlWeb AppAPI ServerDatabaseCacheDocker Network:3000:8080:5432:6379

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=dev in build stage for reproducible installs
  • npm prune --production removes devDependencies before copying to production
  • node:20-bookworm-slim is 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.

OrchestratorNode 1Container AContainer BNode 2Container CContainer ANode 3Container BContainer D

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:

  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.

Free Resource

CI/CD Pipeline Blueprint

Our battle-tested pipeline template covering build, test, security scan, staging, and zero-downtime deployment stages.

Get the Blueprint
# 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
  1. 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.

  2. 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
Terminal$docker compose up -d[+] Running 5/5Network app_default CreatedContainer web StartedContainer api StartedContainer db Started$

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.

#docker#containers#devops#ci-cd#optimization

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.

47+ companies trusted us
99.99% uptime
< 48hr response

No spam. No contracts. Just a free demo.