Une image Docker mal construite, c'est rarement bloquant : ça démarre, ça
répond, on passe à autre chose. Sauf qu'un peu plus tard, on découvre une image
de 1,2 Go qui met deux minutes à se déployer, qui tourne en root, qui contient
le .git complet et un .env avec un token GitHub. Tout ça aurait pu être
évité avec quelques règles simples appliquées en amont.
Voici huit pratiques qui couvrent l'essentiel : taille d'image, vitesse de build, surface d'attaque, fiabilité au runtime.
1. Choisir la bonne image de base
L'image de base décide à elle seule de la taille finale et de ce qui sera scanné par les outils de sécurité. Trois familles à connaître pour un langage comme Node, Python ou Go :
<lang>:<version>— l'image complète, basée sur Debian. Pratique en dev, trop grosse pour la prod (souvent 800 Mo+).<lang>:<version>-slim— Debian minimal, autour de 100-200 Mo. Bon compromis : compatible avec la plupart des dépendances natives.<lang>:<version>-alpine— basé sur Alpine Linux, autour de 40-80 Mo. Très léger mais utilisemuslau lieu deglibc, ce qui casse certaines dépendances natives (Prisma, certaines libs Python compilées).
# Mauvais : image énorme, trop de surface
FROM node:20
# Bon : compromis raisonnable
FROM node:20-slim
# Encore mieux si vos deps sont compatibles
FROM node:20-alpine
Pour aller plus loin : les images distroless de Google (gcr.io/distroless/...)
ne contiennent que le runtime et les libs nécessaires — pas de shell, pas
d'apt, pas d'utilitaires. Excellente surface d'attaque, mais débogage plus
contraignant (pas de docker exec ... sh).
2. Adopter le multi-stage build
C'est probablement la règle qui a le plus d'impact. L'idée : séparer l'étape de build (qui a besoin de compilateurs, dev dependencies, sources) de l'étape runtime (qui n'a besoin que du binaire ou du bundle).
# ── Stage 1 : build ────────────────────────────
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── Stage 2 : runtime ──────────────────────────
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/server.js"]
L'image finale ne contient que le bundle compilé et les dépendances de
production. Le compilateur TypeScript, ESLint, les tests, tout ça reste dans le
stage builder qui est jeté.
Le gain est concret : une appli Node typique passe de 1,1 Go à 150 Mo.
Pour une appli Go ou Rust, on peut tomber sous les 20 Mo en copiant juste
le binaire dans scratch.
3. Optimiser le cache de couches
Docker met en cache chaque instruction du Dockerfile. Si une couche n'a pas changé, elle est réutilisée — mais dès qu'une couche change, toutes les suivantes sont reconstruites.
D'où la règle : mettre ce qui change rarement avant ce qui change souvent.
# ❌ Mauvais : le COPY . copie tout, donc le RUN npm ci se rejoue à chaque
# modification de fichier source
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
# ✅ Bon : npm ci se rejoue seulement si package*.json change
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
La différence en pratique : un build local passe de 45 secondes à 3 secondes sur un changement de code typique. En CI, c'est encore plus visible.
4. Ne jamais tourner en root
Par défaut, le processus dans le conteneur tourne en root (UID 0). Si une
faille permet une évasion ou si un volume est monté avec des permissions
larges, l'attaquant hérite de droits maximum.
La parade : créer un utilisateur dédié et passer dessus avant le CMD.
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --omit=dev
# L'image node fournit déjà un user "node" non-privilégié
USER node
CMD ["node", "server.js"]
Pour les images qui n'ont pas d'utilisateur prédéfini :
RUN addgroup --system app && adduser --system --ingroup app app
USER app
5. Toujours avoir un .dockerignore
Sans .dockerignore, le COPY . . embarque tout : node_modules local,
.git, fichiers de config IDE, fichiers de test, et — le pire — vos .env
avec les secrets.
Un .dockerignore correct pour un projet Node :
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
.vscode
.idea
*.md
coverage
dist
.DS_Store
Bénéfice double : moins de données envoyées au daemon Docker (build plus
rapide), et zéro risque de fuiter un secret en copiant .env par accident.
6. Ne jamais coder en dur les secrets
Trois anti-patterns à bannir :
# ❌ Le secret est dans l'image, visible par quiconque la pull
ENV DATABASE_URL=postgres://user:pass@host/db
# ❌ Le secret est dans une couche, visible avec `docker history`
RUN curl -H "Authorization: Bearer SECRET_TOKEN" https://api/install.sh
# ❌ ARG passé au build, le secret reste dans l'image
ARG API_KEY
RUN echo $API_KEY > /app/key.txt
Les bonnes options :
- Au runtime : passer les secrets via
docker run -e,--env-file, ou l'orchestrateur (Kubernetes secrets, Docker secrets, AWS Secrets Manager). - Au build : utiliser BuildKit avec
--secret, qui monte le secret en tmpfs sans l'inscrire dans une couche.
# syntax=docker/dockerfile:1.7
FROM alpine
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm install
docker build --secret id=npm_token,src=$HOME/.npmrc .
Le secret n'apparaît dans aucune couche finale.
7. Healthcheck et gestion des signaux
Une image bien faite déclare elle-même comment vérifier qu'elle va bien :
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
L'orchestrateur (Docker Swarm, Kubernetes via livenessProbe) peut
redémarrer un conteneur qui ne répond plus.
L'autre piège classique : votre processus tourne en PID 1, ce qui veut
dire qu'il doit gérer lui-même les signaux (SIGTERM, SIGINT) et les
processus zombies. La plupart des runtimes (Node, Python) ne le font pas
correctement par défaut.
Solution : utiliser --init au runtime ou tini dans l'image.
docker run --init myimage
Ou dans le Dockerfile :
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
Sans ça, un docker stop met 10 secondes au lieu d'1 seconde, et les
sous-processus s'accumulent en zombies.
8. Scanner et auditer
Avant de pousser une image en prod, lancez au moins un scan de vulnérabilités. Les outils gratuits suffisent largement :
# Trivy : rapide, signal très propre
trivy image myimage:latest
# Docker Scout : intégré au CLI Docker
docker scout cves myimage:latest
Vous découvrirez des CVE sur des libs système que vous n'utilisez même pas.
Souvent, mettre à jour l'image de base ou passer en -slim/-alpine règle
80% des problèmes.
Checklist mentale
Avant de merger un Dockerfile, vérifier :
- Image de base pinnée (pas
:latest) - Multi-stage si build et runtime ont des deps différentes
- Ordre des
COPY: dépendances avant le code source .dockerignoreprésent et completUSERnon-root déclaré- Aucun secret en
ENV,ARGouRUNbrut HEALTHCHECKdéclaré (sauf si géré par l'orchestrateur)--initau runtime outinidans l'image- Scan
trivyou équivalent sans CVE critique
C'est neuf points, dix minutes à appliquer la première fois, deux minutes ensuite. Le retour sur investissement est immédiat : images plus petites, builds plus rapides, surface d'attaque réduite.