Docker Multi-Stage Builds for Production-Ready Minimal Images
The Dockerfile First
Here's a production Node.js Dockerfile. 45MB final image. Then I'll break down every stage.
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
cp -R node_modules /prod_modules && \
npm ci
# Stage 2: Build
FROM node:20-alpine AS build
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN npm run build && \
npm prune --production
# Stage 3: Production
FROM node:20-alpine AS production
RUN apk add --no-cache dumb-init && \
addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY /app/dist ./dist
COPY /prod_modules ./node_modules
COPY /app/package.json ./
ENV NODE_ENV=production
USER app
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]
Three stages. Only the final one ships. Build tools, dev dependencies, source code — all left behind.
Why Single-Stage Builds Are a Liability
This is what most tutorials teach:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
That image is 1.2GB. It contains:
- The full Node.js runtime with build tools
- All dev dependencies (TypeScript, ESLint, test frameworks)
- Your entire source code including tests
- npm cache
- Git history if you're not careful with
.dockerignore
Every MB is attack surface. Every unnecessary binary is a potential CVE. Ship less.
Stage by Stage
Stage 1: Dependency Resolution
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
cp -R node_modules /prod_modules && \
npm ci
Two installs. First, production-only dependencies get copied to /prod_modules. Then a full npm ci for building. This separation means the final image only gets production deps.
Copying lockfiles first exploits Docker layer caching. If dependencies don't change, this layer is cached. Your code changes don't trigger a reinstall.
Stage 2: Build
FROM node:20-alpine AS build
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN npm run build && \
npm prune --production
Full source code enters. TypeScript compiles. Webpack bundles. Whatever your build step is. The output goes to dist/. The source files, build tools, and dev dependencies stay in this stage forever.
Stage 3: Production
FROM node:20-alpine AS production
RUN apk add --no-cache dumb-init && \
addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY /app/dist ./dist
COPY /prod_modules ./node_modules
COPY /app/package.json ./
Only three things enter the final image: compiled code, production dependencies, and package.json. That's it.
dumb-init handles PID 1 signal forwarding properly. Without it, SIGTERM doesn't reach your Node process and Kubernetes kills your pod the hard way after 30 seconds.
Non-root user. Always. No exceptions.
Go: The Scratch Image Dream
Go compiles to a single binary. Your final image can be almost nothing.
# Stage 1: Build
FROM golang:1.23-alpine AS build
RUN apk add --no-cache ca-certificates git
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /app/server ./cmd/server
# Stage 2: Production
FROM scratch
COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Final image: 12MB. From scratch. No shell. No package manager. No OS. Just your binary and TLS certificates.
-ldflags="-s -w" strips debug info and symbol tables. Saves 30% on binary size.
CGO_ENABLED=0 produces a static binary. No glibc dependency. Runs on scratch.
Python: The Hard One
Python needs a runtime. You can't go to scratch. But you can still cut 80%.
# Stage 1: Build
FROM python:3.12-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir -r requirements.txt
# Stage 2: Production
FROM python:3.12-slim AS production
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 && \
rm -rf /var/lib/apt/lists/* && \
groupadd -r app && useradd -r -g app app
COPY /opt/venv /opt/venv
WORKDIR /app
COPY src/ ./src/
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
USER app
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Key move: build stage has gcc and dev headers for compiling C extensions. Production stage only has runtime libraries (libpq5 not libpq-dev). The compiled wheels in the venv don't need build tools anymore.
Virtual env trick: copy the entire /opt/venv between stages. All dependencies, correct paths, no reinstall.
The .dockerignore Matters
Without this file, COPY . . sends everything to the daemon. Including node_modules, .git, test fixtures, and that 500MB ML model checkpoint.
.git
.gitignore
node_modules
npm-debug.log
Dockerfile
docker-compose*.yml
.dockerignore
.env*
*.md
tests/
coverage/
.github/
.vscode/
dist/
build/
*.log
This file is not optional. It's a security and performance requirement.
CI Integration: Build and Push
# .github/workflows/docker.yml
name: Docker Build
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha uses GitHub Actions cache for Docker layers. First build is slow. Every build after pulls cached layers. Multi-stage builds cache each stage independently — change your code without rebuilding dependencies.
Rust: The Scratch Image Perfection
Rust, like Go, compiles to a single binary. The final image is even smaller.
# Stage 1: Build
FROM rust:1.77-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
# Cache dependencies by building a dummy project
RUN mkdir src && echo "fn main() {}" > src/main.rs && \
cargo build --release && \
rm -rf src
COPY src/ ./src/
RUN cargo build --release
# Stage 2: Production
FROM scratch
COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY /app/target/release/myapp /myapp
EXPOSE 8080
ENTRYPOINT ["/myapp"]
The dummy build step is the key trick. It compiles all dependencies first, creating a cached layer. When only your source code changes, dependencies don't rebuild. This turns a 10-minute build into a 30-second build during iterative development.
Layer Caching Best Practices
The order of instructions in your Dockerfile directly determines how often Docker invalidates the cache. The rule: put things that change least at the top, things that change most at the bottom.
# GOOD: Dependencies cached separately from code
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # Changes rarely
RUN npm ci # Cached when lockfile unchanged
COPY src/ ./src/ # Changes on every commit
RUN npm run build
# BAD: Every code change re-installs all dependencies
FROM node:20-alpine
WORKDIR /app
COPY . . # Changes on every commit
RUN npm ci # Always re-runs
RUN npm run build
The bad version reinstalls every dependency on every build because COPY . . invalidates the layer cache whenever any file changes.
Cache Mounts for Even Faster Builds
BuildKit supports cache mounts that persist between builds:
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN \
pip install -r requirements.txt
COPY . .
RUN python setup.py build
The pip cache persists across builds on the same machine. Combined with multi-stage builds, this means re-installing a single updated dependency doesn't re-download everything else.
Security Hardening in Multi-Stage Builds
Smaller images are inherently more secure, but there are additional steps worth taking.
Non-Root Users
# Always create and switch to a non-root user
RUN addgroup -S app && adduser -S -G app app
USER app
Running as root inside a container means a container escape grants root on the host. This is not theoretical — it's how real breaches work.
Read-Only Filesystem
# In your Kubernetes deployment or docker-compose
# docker-compose.yml
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /app/logs
A read-only filesystem prevents an attacker from dropping malware or modifying binaries. Only mount writable tmpfs for directories that genuinely need writes.
Distroless as an Alternative to Alpine
Google's distroless images contain only the application runtime and its dependencies. No shell. No package manager. No coreutils.
# Stage 1: Build
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
# Stage 2: Distroless
FROM gcr.io/distroless/static-debian12
COPY /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Distroless is harder to debug than Alpine (no shell to exec into), but that's a feature in production. If you need to debug, deploy a debug sidecar. Don't ship debug tools in your production container.
Troubleshooting Multi-Stage Builds
Problem: Build succeeds locally but fails in CI.
Fix: Check that your CI runner has enough memory. Multi-stage builds keep all stages in memory until the final image is assembled. Set DOCKER_BUILDKIT=1 and use --memory limits if needed.
Problem: Final image is larger than expected.
Fix: Inspect layers with docker history myapp:latest. Look for large COPY steps. Common culprits: copying the entire project directory instead of just the build output, or leaving test fixtures in the build stage.
# Find the largest layers
docker history --no-trunc myapp:latest --format "{{.Size}}\t{{.CreatedBy}}"
Problem: COPY --from=build fails with "file not found."
Fix: The file path is relative to the build stage's WORKDIR. If your build stage uses WORKDIR /app, the output is at /app/dist/, not ./dist/.
Problem: Native modules (bcrypt, sharp) crash on Alpine.
Fix: Alpine uses musl libc instead of glibc. Either install the compatibility package (apk add --no-cache libc6-compat) or build the native modules inside the Alpine stage itself.
The Numbers
| Language | Single-stage | Multi-stage | Reduction |
|---|---|---|---|
| Node.js | 1.2 GB | 45 MB | 96% |
| Go | 850 MB | 12 MB | 99% |
| Python | 1.1 GB | 180 MB | 84% |
| Rust | 1.4 GB | 8 MB | 99% |
Smaller images mean faster pulls, faster scaling, faster rollbacks, and fewer vulnerabilities. Your Kubernetes nodes aren't downloading a gigabyte every time a pod reschedules.
Build small. Ship fast. Sleep well.
Related Articles
CI/CD Engineering Lead
Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.
Related Articles
Docker CLI Cheat Sheet
Essential Docker CLI commands organized by task — build images, run containers, manage volumes and networks, compose services, and debug.
Container Supply Chain Security With Sigstore and Cosign
Sign and verify your container images with Sigstore Cosign to prevent supply chain attacks — with keyless signing, SBOM attestation, and Kubernetes admission enforcement.
The Complete Guide to GitHub Actions CI/CD: From Zero to Production-Ready Pipelines
Build production-grade GitHub Actions CI/CD pipelines — from first workflow to reusable workflows, matrix builds, and deployment gates.