Multi-Stage Builds: Production-Ready Slim Docker Images (2026)

Multi-stage builds solve the bloated image problem: your build environment needs compilers, dev dependencies, test tools and build caches — none of which belong in a production image. With multi-stage builds, each FROM instruction starts a fresh stage, and you COPY --from=stagename only what the next stage actually needs. A Go app that needs a 800MB build image ships as a 10MB final image. A React app built with Node ships as a 25MB Nginx image. This phase covers the pattern for every major stack, distroless images, test stages in CI and multi-platform builds.

The Multi-Stage Concept

# Single-stage (bad): ships everything including build tools
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci                    # Includes devDependencies
COPY . .
RUN npm run build
CMD ["npm", "start"]
# Result: ~1.2GB image (node:20 is 1GB+ with full build tools)

# Multi-stage (good): only ships what's needed at runtime
FROM node:20-alpine AS builder   # Stage 1: build
WORKDIR /app
COPY package*.json ./
RUN npm ci                       # Install ALL deps (including dev)
COPY . .
RUN npm run build                # Compile/bundle

FROM nginx:alpine AS runner      # Stage 2: runtime (fresh, tiny image)
COPY --from=builder /app/dist /usr/share/nginx/html
# Result: ~25MB — only nginx + your built files

# Key concepts:
# - Each FROM starts a NEW stage with a CLEAN filesystem
# - COPY --from=stagename pulls files from a previous stage
# - Only the LAST stage becomes the final image (unless --target is used)
# - Earlier stages are cached and reused but not included in final image
# - Stage names (AS builder) are optional but make COPY --from readable

Node.js SPA with Nginx

# Full production Dockerfile: React/Vue/Vite app → Nginx

# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev=false    # Install all deps including devDeps for build

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG VITE_API_URL=https://api.example.com
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build              # Outputs to /app/dist

FROM nginx:1.27-alpine AS runner
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html

# Custom nginx config for SPA (client-side routing)
COPY <

Next.js Standalone

# Next.js standalone output: self-contained Node server without node_modules
# next.config.ts: output: 'standalone'

# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Standalone output creates:
#   .next/standalone/   — minimal Node server
#   .next/static/       — static assets
#   public/             — public files

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Create non-root user
RUN addgroup --system --gid 1001 nodejs \
    && adduser --system --uid 1001 nextjs

# Copy only what's needed from builder
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
# Final image size: ~150MB (Node runtime + minimal app)
# Without standalone: would be ~1.5GB (all node_modules)

Go Binary

# Go compiles to a single static binary — ideal for ultra-minimal images

# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS builder
WORKDIR /app

# Download modules separately for cache
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .

# Build flags for production binary:
# -ldflags="-w -s"  strips debug info and symbol table (~30% smaller)
# CGO_ENABLED=0     static binary (no libc dependency)
# GOOS/GOARCH       target OS/architecture
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-w -s" -o /app/server ./cmd/server

# Option A: scratch (absolute minimum — just the binary)
FROM scratch AS runner-scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/  # HTTPS support
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# Final size: ~10–15MB (just your binary + CA certs)

# Option B: alpine (adds shell, package manager — easier debugging)
FROM alpine:3.19 AS runner-alpine
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/server /server
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 8080
ENTRYPOINT ["/server"]
# Final size: ~20–25MB

Python with uv

# uv (Astral) is the fast Python package installer — replaces pip in Dockerfiles

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
WORKDIR /app

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

# Install dependencies into a virtual env (isolated, easy to copy)
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev --no-install-project

# Copy source and install project
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev

FROM python:3.12-slim AS runner
WORKDIR /app

# Create non-root user
RUN groupadd --gid 1001 appuser \
    && useradd --uid 1001 --gid appuser --no-create-home appuser

# Copy the virtual environment (with all installed packages)
COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv
COPY --from=builder --chown=appuser:appuser /app/src /app/src

ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

USER appuser
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Final size: ~150MB (python:slim + your venv, no build tools)

Distroless Images

# Distroless images (from Google) contain only the runtime — no shell, no package
# manager, no coreutils. Smallest attack surface possible.
# Available for: Java, Node.js, Python, Go (use scratch instead), .NET

# Node.js distroless
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

FROM gcr.io/distroless/nodejs20-debian12 AS runner
WORKDIR /app
COPY --from=builder /app /app
EXPOSE 3000
CMD ["server.js"]   # In distroless: CMD is the script, not ["node", "script.js"]
                    # The entrypoint is already 'node'

# Java distroless (for Spring Boot)
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline
COPY src src
RUN ./mvnw package -DskipTests
RUN java -Djarmode=layertools -jar target/app.jar extract

FROM gcr.io/distroless/java21-debian12 AS runner
WORKDIR /app
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
EXPOSE 8080
CMD ["org.springframework.boot.loader.launch.JarLauncher"]

# Debugging distroless: use the :debug tag which adds busybox shell
FROM gcr.io/distroless/nodejs20-debian12:debug AS runner-debug
# Then: docker exec mycontainer /busybox/sh

Test Stage in CI

# Add a test stage — run tests during build to catch failures early
# Use --target to run only up to the test stage in CI

# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine AS tester
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Run tests — if any fail, docker build exits non-zero
RUN npm run test:ci
RUN npm run lint

FROM nginx:1.27-alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80

# In CI pipeline:
# Step 1: Run tests (build up to test stage — cheap, fails fast)
# docker build --target tester -t myapp:test .

# Step 2: Build final image (only if tests pass)
# docker build --target runner -t myapp:latest .

# GitHub Actions example:
# - name: Test
#   run: docker build --target tester .
# - name: Build
#   run: docker build -t ghcr.io/org/app:${{ github.sha }} .

Size Targets and Audit

# Realistic image size targets by stack:
#
# Stack                       | Single-stage | Multi-stage  | Distroless/scratch
# ──────────────────────────── | ──────────── | ──────────── | ──────────────────
# React/Vue → Nginx            | ~1.2GB       | ~25MB ✅     | N/A
# Next.js standalone           | ~1.5GB       | ~150MB ✅    | N/A
# Node.js API (Alpine)         | ~200MB       | ~80MB ✅     | ~60MB
# Go binary (scratch)          | ~800MB       | ~10MB ✅     | N/A (scratch IS distroless)
# Python API (slim)            | ~800MB       | ~150MB ✅    | ~130MB
# Java Spring Boot             | ~600MB       | ~250MB ✅    | ~200MB

# Audit image layers and size
docker history myapp:latest
docker history myapp:latest --no-trunc   # Full commands

# Dive — interactive image layer explorer (shows what each layer adds)
# brew install dive  /  apt install dive
dive myapp:latest

# Check image size
docker images myapp
docker image inspect myapp:latest --format='{{.Size}}' | numfmt --to=iec

# Reduce image size checklist:
# ✅ Use -alpine or -slim base images
# ✅ Multi-stage: don't ship build tools
# ✅ Clean package manager caches in same RUN layer
#    (or use BuildKit cache mounts — no cleanup needed)
# ✅ .dockerignore: exclude tests, docs, .git
# ✅ --omit=dev for npm (production only)
# ✅ --no-cache-dir for pip
# ✅ rm -rf /var/lib/apt/lists/* after apt install
# ✅ Strip Go binaries: -ldflags="-w -s"
# ✅ Use distroless or scratch for minimal attack surface
Session A complete. You now have a solid foundation: images, CLI, Dockerfiles, Compose, networking, volumes and multi-stage builds. Phase 8 — Docker Security begins Session B: rootless containers, secrets management, image scanning and hardening for production.