Let me guess: your Docker image is 1.5GB. Your actual app? Maybe 50MB. The rest? Build tools, source code, npm cache, Python wheels, and other garbage that has ZERO business being in production.

It's like packing for a weekend trip but bringing your entire wardrobe, your tool chest, AND the instruction manual for your furniture. Ridiculous.

Multi-stage builds fix this. They let you use a heavy build environment and a tiny runtime environment. The result? Images that are 90% smaller, more secure, and deploy 10x faster.

Let me show you how professionals do it in 2025.

If you're using Docker Compose for local development, applying multi-stage builds to your Dockerfiles will dramatically improve both dev and production workflows.

The Problem: Traditional Docker Builds Are Bloated

Typical Node.js Dockerfile (the wrong way):

FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install  # Installs dev dependencies too
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]

Result: 1.2GB image that includes:

  • TypeScript compiler (don't need in production)
  • ESLint, Prettier (don't need in production)
  • Source code (don't need in production)
  • npm cache (don't need in production)

You're shipping a construction site when you should ship a finished house.

The Solution: Multi-Stage Builds

Think of it like this:

Stage 1 (Builder): The construction site. Heavy machinery, tools, raw materials. Messy.

Stage 2 (Runner): The finished house. Clean, minimal, just what you need to live.

You build in Stage 1, then COPY only the final artifacts to Stage 2. Everything else gets left behind.

Example 1: Node.js (The Right Way)

# syntax=docker/dockerfile:1

# ===========================
# Stage 1: Builder
# ===========================
FROM node:20-alpine AS builder

WORKDIR /app

# Copy package files first (layer caching optimization)
COPY package*.json tsconfig.json ./

# Install ALL dependencies (including dev dependencies for building)
RUN --mount=type=cache,target=/root/.npm \
    npm ci

# Copy source code
COPY src ./src

# Build TypeScript to JavaScript
RUN npm run build

# ===========================
# Stage 2: Production Runner
# ===========================
FROM node:20-alpine

WORKDIR /app

# Set production environment
ENV NODE_ENV=production

# Copy package files
COPY package*.json ./

# Install ONLY production dependencies
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production && \
    npm cache clean --force

# Copy ONLY the built JavaScript from builder stage
COPY --from=builder /app/dist ./dist

# Security: Don't run as root
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001
USER nodejs

# Expose port
EXPOSE 3000

# Start the app
CMD ["node", "dist/server.js"]

What changed:

  • Stage 1 (builder): Full build environment. TypeScript, dev tools, everything.
  • Stage 2 (runtime): Only Node.js, production dependencies, and compiled JavaScript.
  • COPY --from=builder pulls ONLY the dist folder from the first stage.

Result:

  • Before: 1.2GB
  • After: 180MB
  • Savings: 85%

Example 2: Python (Django/Flask)

Python is notorious for bloated images because of C extensions that need gcc to install.

# syntax=docker/dockerfile:1

# ===========================
# Stage 1: Builder
# ===========================
FROM python:3.12-slim AS builder

WORKDIR /app

# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    libc6-dev \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Copy requirements
COPY requirements.txt .

# Install Python dependencies (using cache mount for pip)
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir -r requirements.txt

# ===========================
# Stage 2: Production Runner
# ===========================
FROM python:3.12-slim

WORKDIR /app

# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Copy application code
COPY . .

# Create non-root user
RUN useradd -m -u 1001 appuser && \
    chown -R appuser:appuser /app
USER appuser

EXPOSE 8000

CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]

Key points:

  • Builder installs gcc and other build tools
  • Builder creates a virtual environment and installs all packages
  • Production stage copies ONLY the virtual environment (no gcc, no build tools)
  • Production stage uses python:slim (not the full Python image)

Result:

  • Before: 980MB (with gcc and build deps)
  • After: 240MB
  • Savings: 75%

Example 3: Go (The Nuclear Option)

Go compiles to a single static binary. We can go EXTREME with multi-stage builds.

# ===========================
# Stage 1: Build
# ===========================
FROM golang:1.23-alpine AS builder

WORKDIR /app

# Copy dependency files
COPY go.mod go.sum ./
RUN go mod download

# Copy source code
COPY . .

# Build static binary (no CGO, fully standalone)
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o myapp .

# ===========================
# Stage 2: Runtime (FROM SCRATCH!)
# ===========================
FROM scratch

# Copy SSL certificates (for HTTPS)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copy the binary
COPY --from=builder /app/myapp /myapp

# Expose port
EXPOSE 8080

# Run the binary
ENTRYPOINT ["/myapp"]

The magic: FROM scratch is a completely EMPTY image. No OS, no shell, no ls, no nothing. Just your binary.

Result:

  • Before: 450MB (with full Go toolchain)
  • After: 8MB (just the binary + certs)
  • Savings: 98%

This is the smallest possible Docker image. Perfect for microservices.

BuildKit Features (2025 Optimizations)

Modern Docker uses BuildKit. Enable it for superpowers:

export DOCKER_BUILDKIT=1
docker build -t myapp .

1. Cache Mounts (Reuse Caches Between Builds)

Without cache mounts: Every build downloads all npm packages fresh. Slow.

With cache mounts:

RUN --mount=type=cache,target=/root/.npm \
    npm ci

npm cache persists on your HOST machine. Second build? Instant.

Also works for:

  • pip: --mount=type=cache,target=/root/.cache/pip
  • go: --mount=type=cache,target=/go/pkg/mod
  • apt: --mount=type=cache,target=/var/cache/apt

2. Secret Mounts (Don't Leak Secrets in Layers)

Bad:

RUN echo "TOKEN=secret123" > .env
RUN npm install  # Uses token from .env

The secret is now BAKED into your image layers. Anyone with the image can extract it.

Good:

RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm install

# Build with secret
docker build --secret id=npm_token,src=.npmtoken -t myapp .

Secret is used during build but NOT stored in the image.

3. Parallel Stages

If you have multiple independent build steps, BuildKit runs them in parallel:

FROM node:20 AS frontend-build
WORKDIR /app
COPY frontend/ .
RUN npm ci && npm run build

FROM golang:1.23 AS backend-build
WORKDIR /app
COPY backend/ .
RUN go build -o server

FROM nginx:alpine
COPY --from=frontend-build /app/dist /usr/share/nginx/html
COPY --from=backend-build /app/server /usr/local/bin/

Frontend and backend build SIMULTANEOUSLY. Massive speedup.

Security Best Practices

1. Don't Run as Root

Bad:

CMD ["node", "server.js"]  # Runs as root by default

If your app gets hacked, attacker has root access to the container.

Good:

RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup
USER appuser
CMD ["node", "server.js"]  # Runs as appuser

2. Use Specific Image Tags (Not latest)

Bad:

FROM node:latest  # Could be any version tomorrow

Good:

FROM node:20.11-alpine  # Specific, reproducible

Even better:

FROM node:20.11-alpine@sha256:abc123...  # Immutable

3. Scan Images for Vulnerabilities

# Use Docker Scout (built-in 2025)
docker scout cves myapp:latest

# Or Trivy
trivy image myapp:latest

Fix vulnerabilities BEFORE deploying.

Layer Caching Strategy

Docker caches each layer. Order matters:

Bad order (cache breaks often):

COPY . .              # Changes every time you edit code
RUN npm install       # Re-runs every build even if deps unchanged

Good order (cache-friendly):

COPY package*.json ./ # Only changes when dependencies change
RUN npm install       # Cached unless package.json changed
COPY . .              # Changes often, but runs AFTER npm install

The .dockerignore File (Don't Forget This!)

Create .dockerignore to exclude files from being copied:

node_modules
npm-debug.log
.git
.env
.vscode
*.md
Dockerfile
.dockerignore
dist
coverage
.DS_Store

Why it matters: If you don't exclude node_modules, Docker copies 200MB of files THEN deletes them. Huge waste.

Real-World Comparison

Startup deploying a Next.js app:

Before (single-stage):

  • Image size: 1.8GB
  • Push to registry: 4 minutes
  • Pull on deploy: 3 minutes
  • Total: 7 minutes per deployment

After (multi-stage with optimizations):

  • Image size: 210MB
  • Push to registry: 30 seconds
  • Pull on deploy: 20 seconds
  • Total: 50 seconds per deployment

Result: 8x faster deployments. Less storage costs. Happier DevOps team.

Smaller images are critical for Kubernetes deployments where image pull times directly impact pod startup speed and scaling performance.

Image size also directly impacts costs - read cloud cost optimization strategies to understand how optimized images save money on storage and egress bandwidth.

The Checklist

Before you ship:

  • ✅ Multi-stage build (builder + runner)
  • ✅ Use alpine or slim base images
  • COPY --from=builder only final artifacts
  • ✅ Install ONLY production dependencies in final stage
  • ✅ Use cache mounts for package managers
  • ✅ Run as non-root user
  • ✅ Pin image versions (no latest)
  • ✅ Create .dockerignore
  • ✅ Scan for vulnerabilities
  • ✅ Test the final image (does it actually work?)

Common Mistakes

1. Copying the Entire Build Stage

COPY --from=builder /app /app  # DON'T DO THIS
You're defeating the purpose. Copy ONLY what you need:
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

2. Installing Dev Dependencies in Production

RUN npm install  # Installs everything including dev deps
Use:
RUN npm ci --only=production

3. Not Using .dockerignore Copying gigabytes of unnecessary files slows everything down.

4. Too Many Stages Don't overdo it. 2-3 stages is usually enough. More stages = more complexity.

The Bottom Line

Multi-stage builds are THE way to create production Docker images in 2025.

The pattern:

  1. Builder stage: Use full development environment
  2. Runner stage: Use minimal runtime environment
  3. Copy artifacts: ONLY copy built/compiled outputs, not source

The benefits:

  • 80-98% smaller images
  • Faster deployments
  • More secure (fewer attack vectors)
  • Lower storage costs

Start refactoring your Dockerfiles today. Your deploy pipeline (and your AWS bill) will thank you.

Tiny, fast, secure Docker images makes you (look like) a pro.

See multi-stage builds in action: Building serverless APIs with AWS Lambda uses optimized container images for Lambda functions deployed via ECR.