DevOpsil
Docker
88%
Fresh

Docker Multi-Stage Builds for Production-Ready Minimal Images

Sarah ChenSarah Chen9 min read

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 --from=deps /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 --from=build /app/dist ./dist
COPY --from=deps /prod_modules ./node_modules
COPY --from=build /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 --from=deps /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 --from=build /app/dist ./dist
COPY --from=deps /prod_modules ./node_modules
COPY --from=build /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 --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /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 --from=build /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 --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /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 --mount=type=cache,target=/root/.cache/pip \
    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 --from=build /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

LanguageSingle-stageMulti-stageReduction
Node.js1.2 GB45 MB96%
Go850 MB12 MB99%
Python1.1 GB180 MB84%
Rust1.4 GB8 MB99%

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.

Share:
Sarah Chen
Sarah Chen

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

DockerQuick RefFresh

Docker CLI Cheat Sheet

Essential Docker CLI commands organized by task — build images, run containers, manage volumes and networks, compose services, and debug.

Sarah Chen·
3 min read