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.
Table of Contents
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.