Conteneuriser une app avec Docker Compose
Ce guide couvre l'écriture d'un Dockerfile multi-étapes minimaliste et d'un docker-compose.yml qui relient votre application à sa base de données, sans l'image surchargée ni le cache de build cassé qu'un Dockerfile naïf produit.
Build multi-étapes : séparer ce qui compile de ce qui s'exécute
Le conteneur qui compile votre application n'a pas besoin d'être celui qui l'exécute. Donnez à l'étape « builder » tout ce dont elle a besoin : node_modules complet, dépendances de développement, caches de build. Puis ne copiez que le résultat compilé dans une image d'exécution propre.
# Étape 1 : build
FROM node:24.21.0-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Étape 2 : exécution
FROM node:24.21.0-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]Seuls --from=builder /app/dist et les dépendances de production se retrouvent dans l'image finale. D'expérience, ce changement suffit généralement à faire passer un service Node de bien plus d'1 Go à quelques centaines de Mo, dont l'essentiel est l'image de base slim elle-même. Ceci n'est que le squelette. La version complète, avec health check et utilisateur non-root, se trouve à la fin de l'article.
Épinglez votre image de base. FROM node:24-slim paraît inoffensif, mais ce tag bouge, et un rebuild dans six mois peut récupérer une version mineure différente qui ne se comporte pas pareil. node:24.21.0-slim fixe la version de Node (la 24 est la ligne LTS actuelle ; la 20 est en fin de vie). Même un tag exact est republié pour les correctifs de sécurité de l'OS. Pour des builds entièrement reproductibles, épinglez aussi le digest (node:24.21.0-slim@sha256:...) et laissez Renovate ou Dependabot ouvrir les PR qui le mettent à jour.
Ordonner ses instructions pour le cache, pas pour la lisibilité
Docker met en cache chaque couche et ne l'invalide (elle et tout ce qui suit) que lorsque ses entrées changent. L'erreur la plus fréquente consiste à copier tout le projet avant d'installer les dépendances. Résultat : un npm install (ou pip install, ou bundle install) complet à chaque ligne de code source modifiée.
Copiez d'abord uniquement le manifeste des dépendances, installez, puis copiez le reste du code source :
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Modifier src/routes/users.ts invalide désormais la couche COPY . . et tout ce qui la suit, alors que la couche npm ci au-dessus reste en cache. Sur un runner CI avec un cache chaud, le build se termine alors généralement en quelques secondes au lieu de tout réinstaller pendant une minute ou plus.
Un .dockerignore pour garder un contexte de build honnête
Le « contexte de build » est le répertoire que vous passez à docker build (généralement .). BuildKit, le builder par défaut, ne transfère que les fichiers dont votre Dockerfile a besoin et réutilise ce qu'il a déjà reçu. Il n'envoie donc pas tout aveuglément comme l'ancien builder. Un .dockerignore reste utile. Les fichiers exclus sont invisibles pour le build, donc un COPY . . large ne peut pas embarquer votre node_modules local, .git ou un .env rempli de vrais identifiants. COPY . . est aussi moins souvent invalidé par des fichiers sans effet sur l'image (chaque commit modifie .git). Enfin, moins de données partent vers le builder, ce qui se remarque avec des builders distants et en CI.
node_modules
npm-debug.log
dist
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
.dockerignore
README.md
.vscode
coverageSans lui, ce COPY . . peut figer dans une couche d'image un fichier .env contenant de vrais identifiants. Les couches sont mises en cache et réutilisées, donc supprimer le fichier dans une étape RUN ultérieure ne l'efface pas de l'image. Il reste dans la couche précédente, et n'importe qui pouvant récupérer l'image peut l'en extraire.
Assembler le tout avec Docker Compose
Un seul conteneur suffit rarement. La plupart des applications ont besoin d'une base de données à côté, au moins pour le développement local. Compose permet de décrire toute la pile, avec un volume nommé pour que vos données Postgres survivent à un docker compose down. (Vous verrez encore une clé version: de premier niveau dans d'anciens exemples. Elle est obsolète ; Compose l'ignore aujourd'hui et affiche un avertissement.)
services:
app:
build:
context: .
target: runtime
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:app@db:5432/app
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
healthcheck:
# les images slim n'ont pas curl ; Node 18+ intègre fetch, on utilise donc node lui-même
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
db:
image: postgres:16.4-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7.4-alpine
volumes:
pgdata:Démarrez la pile avec le plugin Compose v2, docker compose avec un espace (l'ancien binaire docker-compose n'est plus maintenu) :
docker compose up -d --build
docker compose ps # la colonne STATUS affiche (healthy) dès que /healthz répondSans condition: service_healthy, depends_on attend seulement que le conteneur démarre, pas que Postgres accepte réellement des connexions, votre application va crash-looper lors de ses premières tentatives de connexion contre une base qui n'est pas encore prête.
HEALTHCHECK, utilisateur non-root et image légère
Deux habitudes séparent un conteneur qui survit en production d'un conteneur qui fonctionne simplement par chance sur votre poste.
D'abord, un HEALTHCHECK dans le Dockerfile permet à docker ps et à Compose de vérifier que l'application sert bien du trafic, puisqu'un processus peut continuer de tourner longtemps après avoir cessé de répondre. Les images -slim n'incluent pas curl, donc un contrôle curl -f marquerait le conteneur unhealthy en permanence. Utilisez le runtime Node déjà présent :
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]127.0.0.1 plutôt que localhost évite un faux échec quand localhost résout d'abord en IPv6 alors que l'application n'écoute qu'en IPv4. Kubernetes ignore les HEALTHCHECK du Dockerfile et utilise ses propres sondes. Là, vous pointerez une readinessProbe sur le même /healthz.
Ensuite, exécutez le conteneur avec un utilisateur non-root. Les images officielles node fournissent d'ailleurs un utilisateur node exactement pour cette raison :
USER nodeFaire tourner un conteneur en root n'est pas un risque théorique. Une évasion de conteneur combinée à un processus root à l'intérieur donne à un attaquant les droits root sur tout ce vers quoi il s'échappe. Cela coûte une seule ligne à éviter, et tout scanner de sécurité d'image de base signalera son absence.
En assemblant le tout, voici le Dockerfile complet :
# Étape 1 : build
FROM node:24.21.0-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Étape 2 : exécution
FROM node:24.21.0-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# On quitte root avant le démarrage de l'app ; les fichiers copiés restent à root, en lecture seule pour l'app
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["node", "dist/server.js"]Enfin, vérifiez ce que vous avez livré. docker images donne la taille, et docker history <image> montre quelle couche l'a produite. Lancez-le avant qu'une image n'approche un registre. C'est le moyen le plus rapide de repérer un apt-get égaré ou un dossier de cache oublié avant qu'il ne pèse des centaines de Mo pour rien.
L'écart entre un Dockerfile naïf et un Dockerfile pensé pour la production tient à une poignée d'habitudes : builds multi-étapes, ordre des instructions pensé pour le cache, .dockerignore, HEALTHCHECK, et utilisateur non-root.
Envie de faire tourner ça en production ?
Ce tutoriel couvre les concepts et l'architecture. Si vous voulez l'implémenter dans votre propre infrastructure, ou monter en compétence pour posséder ce sujet durablement, je propose du mentorat individuel construit autour de votre environnement réel, pas une formation générique.
Ce tutoriel
- Concepts clés et architecture principale
- Extraits de code illustratifs
- Le raisonnement derrière chaque décision
Mentorat individuel
- Des sessions de travail sur votre propre environnement
- Des réponses directes aux cas particuliers que vous rencontrez
- Un retour sur votre implémentation réelle
- Un accompagnement continu pendant que vous la construisez