DevToolBoxGRATUIT
Blog

Docker Multi-Stage Builds : Optimiser les Images pour la Production

12 minpar DevToolBox

Docker multi-stage builds allow you to use multiple FROM statements in a single Dockerfile. Each stage is a fresh environment — you compile, test, and package your application in separate stages, then copy only the final artifacts into a minimal production image. The result: dramatically smaller images, better security, and cleaner build processes.

Why Multi-Stage Builds?

Traditional Dockerfiles ship everything used during the build — compilers, dev dependencies, test frameworks. Multi-stage builds isolate the build environment from the runtime environment.

  • Image size reduction: a Node.js app can go from 1.2 GB to 150 MB
  • Security: fewer packages = smaller attack surface. No compiler, no build tools in production
  • Build caching: each stage caches independently, speeding up subsequent builds
  • Separation of concerns: build, test, and runtime are clearly separated

Node.js: The Classic Pattern

The most common use case: compile TypeScript or bundle with webpack, then copy only the dist folder and production dependencies.

# Single-stage build (bad — ships with devDependencies and build tools)
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install          # includes devDependencies
COPY . .
RUN npm run build
# Final image: ~1.2 GB (includes all node_modules, source, build tools)

---

# Multi-stage build (good — minimal production image)
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci               # clean install
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production

# Only copy what's needed
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production   # only production deps

COPY --from=builder /app/dist ./dist

EXPOSE 3000
USER node                # run as non-root
CMD ["node", "dist/server.js"]

# Final image: ~150 MB (85% reduction!)

Go: Static Binary (FROM scratch)

Go produces static binaries that can run in a FROM scratch container — just the binary, no OS, no shell. The smallest possible images.

# Go: the ultimate multi-stage build
# Go compiles to a static binary — final image can be FROM scratch

# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app

# Download dependencies first (better cache)
COPY go.mod go.sum ./
RUN go mod download

# Build the binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server

# Stage 2: Ultra-minimal runtime (zero OS!)
FROM scratch AS production
# Copy SSL certificates for HTTPS calls
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy just the binary
COPY --from=builder /app/main /main

EXPOSE 8080
ENTRYPOINT ["/main"]
# Final image: ~10 MB (just the binary + CA certs)

Python: Compiled Wheels Pattern

Python often needs system libraries (gcc, libpq) to compile C extensions. Build the wheels in Stage 1, install pre-built wheels in Stage 2 without build tools.

# Python: multi-stage with dependency caching
# Stage 1: Build wheels (compiled dependencies)
FROM python:3.12-slim AS builder
WORKDIR /app

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

COPY requirements.txt .
# Build wheels so Stage 2 doesn't need gcc
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt

# Stage 2: Runtime
FROM python:3.12-slim AS production
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Only install runtime deps (no gcc needed)
RUN apt-get update && apt-get install -y --no-install-recommends     libpq5     && rm -rf /var/lib/apt/lists/*

# Install pre-built wheels
COPY --from=builder /app/wheels /wheels
RUN pip install --no-cache /wheels/*

COPY . .

# Create non-root user
RUN addgroup --system app && adduser --system --group app
USER app

CMD ["gunicorn", "myapp.wsgi:application", "--bind", "0.0.0.0:8000"]

Including a Test Stage

Add a dedicated test stage that runs your test suite. Use --target to build only specific stages in CI.

# Multi-stage with test stage
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

# Build stage
FROM deps AS builder
COPY . .
RUN npm run build

# Test stage (run in CI, skip in production builds)
FROM builder AS test
RUN npm run test
RUN npm run lint

# Production stage (the default target)
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules  # share deps
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]

# Build commands:
# docker build .                          # builds production (default)
# docker build --target test .            # runs tests only
# docker build --target builder .         # build artifacts only

Build Arguments Across Stages

ARG values must be re-declared in each stage where they are used. ENV variables do not carry across stages either.

# Using ARG and ENV across stages
# Build arguments (available at build time)
ARG NODE_VERSION=20
ARG APP_VERSION=1.0.0

FROM node:${NODE_VERSION}-alpine AS builder
WORKDIR /app

ARG APP_VERSION
# Bake version into the binary at build time
ENV APP_VERSION=${APP_VERSION}

COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:${NODE_VERSION}-alpine AS production
WORKDIR /app

# ARG must be redeclared in each stage where you use it
ARG APP_VERSION
LABEL version="${APP_VERSION}"
ENV NODE_ENV=production
ENV APP_VERSION=${APP_VERSION}

COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --only=production

EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]

# Build with:
# docker build --build-arg NODE_VERSION=22 --build-arg APP_VERSION=2.1.0 .

Size Comparison by Language

Language / FrameworkSingle-Stage SizeMulti-Stage SizeSavings
Node.js (React app)1.2 GB~150 MB87%
Go (REST API)800 MB~10 MB99%
Python (Django)900 MB~200 MB78%
Java (Spring Boot)1.5 GB~250 MB83%
Rust (binary)2 GB~15 MB99%

Optimization Best Practices

  1. Order instructions from least to most frequently changed. COPY package.json before COPY . . to maximize cache hits.
  2. Use alpine or slim base images to reduce size and attack surface.
  3. Always run as a non-root user in production stages.
  4. Combine RUN commands with && to reduce layers: RUN apt-get update && apt-get install -y pkg && rm -rf /var/lib/apt/lists/*
  5. Use .dockerignore to exclude node_modules, .git, tests, and docs from the build context.
  6. Name your stages (AS builder) so you can reference them explicitly and use --target in CI.

Frequently Asked Questions

Can I cache multi-stage builds in CI?

Yes. Use Docker BuildKit with cache-from: docker buildx build --cache-from type=gha --cache-to type=gha,mode=max. GitHub Actions has native BuildKit cache support via actions/cache and docker/build-push-action. Each stage caches independently so only changed stages rebuild.

How do I share data between stages?

Use COPY --from=stage_name to copy files from a named stage. You can only copy files, not environment variables or build cache. For sharing between unrelated images, use build volumes (docker build --mount) with BuildKit.

Does --target skip unused stages?

Yes. When you specify --target builder, Docker only builds stages up to and including the target. Subsequent stages are skipped entirely. This makes CI faster when you only need to run tests without building the final production image.

Can I have multiple final stages (different deployment targets)?

Yes. You can have multiple "final" stages that share a common builder stage. Use --target to build the one you need. Common pattern: a production stage with Node.js and a migration stage that runs database migrations, both built from the same compiled output.

How do I use a private registry image in a FROM scratch stage?

FROM scratch literally creates an empty image — no OS, no shell, no package manager. You must copy everything your binary needs: the binary itself, SSL certificates (for HTTPS), timezone data (if needed), and any dynamic libraries. For Go, compile with CGO_ENABLED=0 to get a static binary that needs nothing else.

Related Tools

𝕏 Twitterin LinkedIn
Cet article vous a-t-il aidé ?

Restez informé

Recevez des astuces dev et les nouveaux outils chaque semaine.

Pas de spam. Désabonnez-vous à tout moment.

Essayez ces outils associés

🐳Docker Run to Compose{ }JSON Formatter#Hash Generator

Articles connexes

Kubernetes pour débutants : Tutoriel complet (2026)

Apprenez Kubernetes de zéro. Pods, Services, Deployments et plus.

GitHub Actions Secrets et Sécurité : Environnements, OIDC et Bonnes Pratiques

Sécuriser les workflows GitHub Actions avec secrets chiffrés, OIDC et bonnes pratiques.