Développement Web

Docker multi-stage builds : tutoriel optimisation 2026

12 min de lecture

Le multi-stage build Docker est la technique la plus efficace pour réduire la taille de vos images de 5-10x. Au lieu d’un seul stage qui contient build tools + code source + node_modules dev + binaire, on sépare en stages : build (gros) puis runner (minimal).

Voir notre guide Docker complet.

Pattern Node.js

FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN 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 runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
RUN npm ci --only=production
USER node
CMD ["node", "dist/server.js"]

Pattern Go

FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/myapp ./cmd/myapp

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/myapp /myapp
USER nonroot:nonroot
ENTRYPOINT ["/myapp"]

Image finale Go : ~10-20 Mo. Distroless n’a même pas de shell, surface d’attaque minimale.

Pattern Python

FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --user --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt --output requirements.txt
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.12-slim AS runner
ENV PATH=/root/.local/bin:$PATH
COPY --from=builder /root/.local /root/.local
COPY . /app
WORKDIR /app
USER 1000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]

Pattern Bun

FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun build src/index.ts --target=bun --outdir ./dist

FROM oven/bun:1-slim AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER bun
CMD ["bun", "run", "dist/index.js"]

Astuces

  • Order matters : copier package.json d’abord, lock, install — Docker cache les layers
  • .dockerignore : exclure node_modules, .git, .env, *.log
  • distroless ou scratch pour Go/Rust : surface attaque minimale
  • Alpine pour Node/Python : ~50-80 Mo de base, attention aux différences glibc/musl
  • Slim Debian : équilibre entre taille et compatibilité

À lire ensuite

Hostinger pour vos premiers déploiements

Le panel hPanel reste l’un des plus accessibles du marché pour les débutants en self-hosting.

Découvrir hPanel →

Lien d affiliation. Si vous achetez via ce lien, le blog reçoit une petite commission sans surcoût pour vous.

Étape 1 : poser le contexte d’un build multi-stage

Avant d’écrire la moindre ligne de Dockerfile, il faut comprendre le problème que le multi-stage build résout. Un build classique embarque toutes les dépendances de compilation (compilateurs, en-têtes, gestionnaires de paquets) dans l’image finale. Résultat : une image Node.js peut peser 1,2 Go alors que le runtime n’a besoin que de 180 Mo. Sur un VPS Contabo à 6 EUR/mois (3 935 FCFA) avec 50 Go de stockage, cette obésité fait exploser la facture de transfert et ralentit chaque déploiement.

Le multi-stage build, introduit dans Docker 17.05 et stabilisé depuis, permet de chaîner plusieurs FROM dans un même Dockerfile. Chaque stage est une image intermédiaire, et seul le dernier stage produit l’image livrée. On compile dans le stage builder, on copie le binaire ou le bundle dans le stage runtime, et on jette tout le reste. Vous obtenez une image 5 à 10 fois plus légère, sans toolchain, sans secrets de build oubliés dans une couche.

Étape 2 : Dockerfile multi-stage Node.js minimal

Voici un Dockerfile complet pour une API Express. Le premier stage installe les dépendances de développement et compile TypeScript. Le second stage ne contient que Node.js et le code transpilé.

# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Construisez avec docker build -t api:1.0 . puis vérifiez la taille via docker images api. Vous devez voir une image entre 180 et 220 Mo. Si vous voyez 900 Mo, c’est que le stage runtime a hérité des devDependencies : relisez le --omit=dev.

Étape 3 : multi-stage pour Go et binaire statique

Go produit des binaires statiques, ce qui rend le multi-stage encore plus radical. On compile sur golang:1.23-alpine, puis on copie le binaire dans une image scratch totalement vide.

FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/api

FROM scratch
COPY --from=builder /out/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 1000
ENTRYPOINT ["/app"]

L’image finale fait 8 à 15 Mo. Le flag CGO_ENABLED=0 garantit un binaire sans dépendance C, et -ldflags="-s -w" retire les symboles de debug pour gagner 30 % supplémentaires. La copie des certificats CA est obligatoire si votre app fait des appels HTTPS sortants.

Étape 4 : cache des couches et BuildKit

Activez BuildKit (par défaut depuis Docker 23) pour bénéficier du cache parallèle. Ajoutez --mount=type=cache sur les commandes coûteuses :

RUN --mount=type=cache,target=/root/.npm     npm ci --prefer-offline

Le cache npm survit entre les builds, ce qui réduit un npm ci de 90 secondes à 6 secondes sur une connexion ADSL Sonatel à Dakar. Ordonnez vos COPY du moins volatile au plus volatile : package.json avant le code source, sinon chaque modification d’un fichier .ts invalide tout le cache.

Étape 5 : sécurité et utilisateur non-root

Une image qui tourne en root est un risque inacceptable en production. Le multi-stage facilite la bascule vers un utilisateur dédié. Sur Alpine, créez un user app dans le stage runtime :

RUN addgroup -S app && adduser -S app -G app
USER app

Vérifiez avec docker run --rm api:1.0 whoami. La sortie doit être app et non root. Combiné avec --read-only et --cap-drop=ALL au runtime, vous bloquez 90 % des techniques d’escalade conteneur.

Étape 6 : scanner l’image avec Trivy

Avant chaque push vers un registre, scannez l’image. Trivy v0.58+ détecte les CVE des paquets OS et des dépendances applicatives :

trivy image --severity HIGH,CRITICAL --exit-code 1 api:1.0

Le flag --exit-code 1 fait échouer la CI si une CVE critique est détectée. Sur GitLab CI ou GitHub Actions, ajoutez ce job en gate avant le déploiement. Une image multi-stage Alpine récente affiche typiquement 0 à 2 CVE HIGH, contre 30+ pour une image Debian classique non multi-stage.

Étape 7 : push vers un registre privé

Pour un projet hébergé chez OVH ou Scaleway, utilisez le registre intégré ou un Harbor auto-hébergé. Authentifiez-vous puis taguez l’image :

docker tag api:1.0 registry.example.sn/team/api:1.0
docker push registry.example.sn/team/api:1.0

L’image fait 200 Mo au lieu de 1,2 Go : le push prend 12 secondes au lieu de 3 minutes sur une fibre Orange à Abidjan. Multipliez par 50 déploiements par jour, vous économisez 2h30 de temps de cycle quotidien à votre équipe.

Étape 8 : pièges courants à éviter

Trois erreurs reviennent dans les audits que nous menons sur les Dockerfiles d’équipes ouest-africaines. Premièrement, oublier .dockerignore : sans lui, le contexte de build envoie tout node_modules au démon Docker, ralentissant chaque build de 30 secondes. Deuxièmement, copier des secrets via COPY .env dans un stage builder : même si l’image finale ne le contient pas, le stage intermédiaire reste accessible avec docker history. Utilisez --secret de BuildKit. Troisièmement, fixer les tags :latest : verrouillez toujours node:22.11.0-alpine3.20, sinon un build reproductible devient impossible.

Pour approfondir

Continuez avec notre guide de hardening VPS 2026 pour sécuriser le serveur qui exécute vos conteneurs, ou explorez le tutoriel PostgreSQL pgvector pour une stack data complète.

Étape 9 : optimiser une image Python multi-stage

Python pose un défi spécifique : les wheels précompilés (numpy, psycopg2, pillow) embarquent des bibliothèques C qui doivent être présentes au runtime. Le piège classique est de compiler avec python:3.13 puis de copier vers python:3.13-slim et de découvrir que libpq manque. La solution propre :

FROM python:3.13-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends     build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

FROM python:3.13-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends libpq5     && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache /wheels/*
COPY . .
RUN useradd -m -u 1000 app
USER app
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]

Le stage builder produit des wheels dans /wheels, le runtime installe ces wheels sans avoir à recompiler. L’image finale pèse 180 à 250 Mo selon les dépendances, contre 1,1 Go en single-stage. Si pip install rame derrière la connexion d’un poste à Lomé ou Cotonou, ajoutez un mirror PyPI régional via pip config set global.index-url.

Étape 10 : multi-stage et fichiers statiques frontend

Pour une SPA React ou Vue servie par Nginx, le multi-stage permet d’enchaîner build Node + serveur statique sans embarquer Node en production.

FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

L’image finale tombe à 45 Mo. Le fichier nginx.conf doit ajouter les en-têtes de sécurité (CSP, X-Frame-Options, X-Content-Type-Options) et activer la compression gzip pour les assets. Vérifiez avec curl -I http://localhost/ que Content-Encoding: gzip est bien présent.

Étape 11 : mesurer et comparer les tailles

Adoptez une discipline de mesure systématique. À chaque PR qui touche un Dockerfile, comparez la taille de l’image avant et après :

docker images --format "{{.Repository}}:{{.Tag}} {{.Size}}" | grep api
docker history api:1.0 --format "{{.Size}}	{{.CreatedBy}}"

L’output liste chaque couche et sa taille. Une couche de 400 Mo qui contient npm install dans le stage runtime est un signal rouge : votre multi-stage est cassé. Outil complémentaire : dive permet d’explorer interactivement les couches et d’identifier les fichiers gaspillés. Sur un projet client à Dakar, nous avons identifié 180 Mo de fichiers .git oubliés via dive en 30 secondes.

Étape 12 : intégration dans une CI GitHub Actions

Voici un workflow GitHub Actions qui construit l’image multi-stage avec cache distribué et la pousse vers GHCR :

name: build
on: [push]
jobs:
  docker:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write
    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
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Le cache type=gha stocke les couches dans GitHub Actions Cache (10 Go gratuits par dépôt). Premier build : 4 minutes. Builds suivants sans changement de dépendances : 35 secondes. Sur un projet avec 30 commits par jour, le gain cumulé est d’environ 2 heures de CI quotidiennes.

Étape 13 : runtime distroless et durcissement final

Pour aller au bout de la démarche, remplacez l’image runtime Alpine par une image distroless de Google. Distroless ne contient ni shell, ni gestionnaire de paquets, ni busybox : juste le runtime nécessaire à votre app. Un attaquant qui obtient un RCE ne peut même pas exécuter sh ou curl.

FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production

FROM gcr.io/distroless/nodejs22-debian12 AS runtime
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER nonroot
CMD ["dist/server.js"]

L’image fait 160 Mo, sans aucun binaire utilitaire. Pour Go, l’équivalent gcr.io/distroless/static-debian12 pèse 2 Mo. Vérifiez l’absence de shell : docker run --rm --entrypoint sh api:1.0 doit échouer avec « executable file not found ». C’est un signal de bonne santé.

Étape 14 : checklist de revue de Dockerfile

Avant de merger une PR qui ajoute ou modifie un Dockerfile, validez chaque point de la checklist suivante. Tag d’image épinglé sur une version précise et un digest si possible. Au moins deux stages : un builder et un runtime. --omit=dev ou équivalent dans le stage runtime. Utilisateur non-root activé via USER. Fichier .dockerignore qui exclut node_modules, .git, .env, coverage, tests. Aucun secret en clair (utiliser --secret ou variables d’environnement injectées au runtime). Healthcheck défini via HEALTHCHECK CMD pour que l’orchestrateur sache redémarrer un conteneur défaillant. Taille finale documentée dans le README. Scan Trivy passé sans CVE HIGH. Cette discipline rend chaque Dockerfile auditable et reproductible, ce qui est exigé par les clients européens et les grands comptes ouest-africains qui imposent ISO 27001 ou PCI-DSS.

Étape 15 : automatiser la mise à jour des images de base

Les images Alpine et Debian publient des correctifs de sécurité chaque semaine. Configurez Renovate ou Dependabot pour ouvrir automatiquement une PR dès qu’une nouvelle version mineure de node:22-alpine ou postgres:17-alpine est publiée. Combinez avec un build automatique nocturne dans la CI pour rebâtir l’image avec le dernier patch sans toucher au code applicatif. Cette pratique réduit la fenêtre d’exposition aux CVE de plusieurs semaines à 24 heures, et reste compatible avec un pipeline GitOps déployé sur Kubernetes ou Docker Swarm.

Partager