# Images Docker pour Node en 2026 : multi-étapes, distroless, et l'image de 1,1 Go devenue 140 Mo

Le premier Dockerfile de la plupart des projets Node fait onze lignes, part de `node:20`, copie le dépôt, lance `npm install` et `npm run build`, et livre. Ça marche. Ça fait aussi 1,1 Go, contient un compilateur C, Python, git, toutes les dépendances de développement, le répertoire `.git`, et la source de l'application à côté de son build. C'est ce que nous avons hérité sur une plateforme Next.js que nous exploitons pour un client américain, et ça coûtait de vraies minutes à chaque déploiement et de vrais euros en stockage ECR et en temps de pull App Runner.

Voici le Dockerfile que nous utilisons maintenant, à 140 Mo, avec le raisonnement de chaque étape, les deux choses qui ont cassé au passage à distroless, et la note honnête sur ce que vous abandonnez.

## D'où venaient les 1,1 Go

| Couche | Taille | Pourquoi elle était là |
|---|---|---|
| Base `node:20` (Debian) | 1 000 Mo | Debian complet avec la chaîne de build, Python, git |
| `node_modules` avec les dépendances de dev | 420 Mo | TypeScript, ESLint, Jest, Playwright, tous livrés en prod |
| Arborescence source + `.git` | 80 Mo | Copiée avant que `.dockerignore` existe |
| Sortie du build | 60 Mo | La seule partie dont la production a besoin |

Quatre de ces cinq lignes sont du gaspillage dans le conteneur qui tourne. L'image de base seule fait sept fois l'application. Et chaque couche est repoussée vers ECR et retirée par chaque instance App Runner à chaque montée en charge, ce qui ajoutait vingt à quarante secondes à chaque démarrage à froid.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Deux barres empilées comparant la composition de l'image. Avant : 1,1 Go composé de la base Debian 1000 Mo, node_modules avec dépendances de dev 420 Mo, source et git 80 Mo, sortie de build 60 Mo. Après : 140 Mo composé de la base distroless 20 Mo, node_modules de production 60 Mo, sortie standalone 60 Mo.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="26" fill="#f1f3ff" font-size="14" font-weight="700">Ce qu'il y a dans l'image</text>
<text x="20" y="66" fill="#9aa3c7">avant · 1,1 Go</text>
<rect x="140" y="52" width="480" height="22" rx="4" fill="#ff6b8a" opacity="0.85"/><text x="380" y="67" text-anchor="middle" fill="#0d1120" font-weight="700">base node:20 Debian · 1 000 Mo</text>
<rect x="620" y="52" width="200" height="22" rx="4" fill="#ffd166" opacity="0.85"/><text x="720" y="67" text-anchor="middle" fill="#0d1120" font-size="10">dép. de dev · 420 Mo</text>
<rect x="820" y="52" width="38" height="22" rx="4" fill="#9aa3c7" opacity="0.85"/>
<rect x="858" y="52" width="28" height="22" rx="4" fill="#4fffb0"/>
<text x="20" y="126" fill="#9aa3c7">après · 140 Mo</text>
<rect x="140" y="112" width="20" height="22" rx="4" fill="#7b8cff"/>
<rect x="160" y="112" width="58" height="22" rx="4" fill="#ffd166" opacity="0.85"/>
<rect x="218" y="112" width="58" height="22" rx="4" fill="#4fffb0"/>
<text x="290" y="127" fill="#f1f3ff">distroless 20 Mo · dép. de prod 60 Mo · sortie standalone 60 Mo</text>
<text x="140" y="158" fill="#9aa3c7" font-size="11">échelle : 1 px ≈ 1,5 Mo · l'application elle-même (vert) a la même taille dans les deux</text>
<g font-size="11"><rect x="140" y="184" width="12" height="12" fill="#ff6b8a"/><text x="158" y="194" fill="#9aa3c7">OS de base</text><rect x="240" y="184" width="12" height="12" fill="#ffd166"/><text x="258" y="194" fill="#9aa3c7">node_modules</text><rect x="370" y="184" width="12" height="12" fill="#9aa3c7"/><text x="388" y="194" fill="#9aa3c7">source + .git</text><rect x="490" y="184" width="12" height="12" fill="#4fffb0"/><text x="508" y="194" fill="#9aa3c7">sortie de build</text><rect x="620" y="184" width="12" height="12" fill="#7b8cff"/><text x="638" y="194" fill="#9aa3c7">runtime distroless</text></g>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">Sept huitièmes de l'ancienne image ne tournaient jamais. Ils étaient poussés, stockés et tirés quand même.</text>
</g>
</svg>
</div>

## Le Dockerfile

```dockerfile
# syntax=docker/dockerfile:1.7

# ---- 1. deps : installer avec le lockfile, mettre le store en cache ----
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN corepack enable
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/
COPY packages/*/package.json packages/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

# ---- 2. build : compiler, produire la sortie standalone ----
FROM deps AS build
COPY . .
ARG RELEASE_SHA
ENV NEXT_TELEMETRY_DISABLED=1 RELEASE_SHA=$RELEASE_SHA
RUN pnpm turbo build --filter=web
# ne garder que les dépendances de production, pour les paquets dont la sortie standalone a besoin
RUN pnpm --filter=web deploy --prod /out

# ---- 3. runtime : rien d'autre que node et la sortie ----
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build --chown=nonroot:nonroot /app/apps/web/.next/standalone ./
COPY --from=build --chown=nonroot:nonroot /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build --chown=nonroot:nonroot /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["apps/web/server.js"]
```

Et le `.dockerignore` qui fait plus de travail que n'importe quelle ligne du Dockerfile :

```
.git
node_modules
**/node_modules
**/.next
**/dist
**/coverage
.env*
*.md
```

### Étape 1, deps

Seuls les manifestes et le lockfile sont copiés avant `pnpm install`, donc la clé de cache de cette couche est le lockfile. Changez un fichier source et la couche d'installation est réutilisée. Changez le lockfile et elle se relance, ce qui est correct. Le `--mount=type=cache` conserve le store adressé par contenu de pnpm entre les builds sur le même builder, donc même un changement de lockfile touche surtout le store local plutôt que le registre. Sur CodeBuild avec le [cache de couches du registre](/fr/blog/monorepo-three-apps-build-only-what-changed), cette étape est restaurée en une dizaine de secondes.

`node:22-bookworm-slim` plutôt que l'image complète : 200 Mo au lieu d'un gigaoctet, avec encore un shell et un gestionnaire de paquets pour les rares modules natifs qui doivent compiler. Nous n'avons besoin ni de Python ni de gcc pour cette application ; si vous en avez besoin, installez-les uniquement dans cette étape.

### Étape 2, build

Copie la source par-dessus l'étape deps et lance le build. `output: 'standalone'` dans `next.config.js` est le réglage clé : il trace quels fichiers de `node_modules` le serveur importe réellement et ne copie que ceux-là dans `.next/standalone`, avec un `server.js` minimal. C'est ce qui transforme 420 Mo de `node_modules` en 60 Mo.

`pnpm deploy --prod`, c'est la ceinture et les bretelles : pour tout ce que le traceur standalone rate (nous avions un `require` dynamique dans une bibliothèque PDF), il produit une installation propre, uniquement de production, des dépendances de l'application.

### Étape 3, runtime

Distroless. Pas de shell, pas de gestionnaire de paquets, pas d'`apt`, pas de `curl`, pas de `sh`. Juste le binaire Node, ses bibliothèques d'exécution, les certificats CA, et les fichiers que nous copions. Tourne par défaut sous l'utilisateur `nonroot`. Toute la base fait 20 Mo.

C'est l'étape qui compte pour les scanners de sécurité : la base Debian traînait environ 180 CVE dans des paquets que l'application n'appelait jamais, la plupart dans des outils comme `perl` et `git`. Distroless en porte une poignée, toutes dans Node lui-même, que vous corrigez en montant le tag.

## Les deux choses qui ont cassé

**1. Le health check utilisait `curl`.** L'ancienne définition de tâche avait un health check de conteneur `curl -f http://localhost:3000/api/health`. Il n'y a pas de `curl` dans distroless. Il n'y a pas de shell non plus, donc `CMD-SHELL` échoue aussi. Deux correctifs, et nous avons utilisé le second : soit utiliser le health check HTTP de la plateforme plutôt qu'un check de conteneur (App Runner et les target groups ALB font tous deux du HTTP nativement, et [la route devrait de toute façon être conçue pour ça](/fr/blog/health-checks-that-lie)), soit livrer un minuscule script de health en Node et le lancer avec le binaire Node :

```dockerfile
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD ["/nodejs/bin/node", "apps/web/healthcheck.js"]
```

**2. Le débogage dans le conteneur a cessé de fonctionner.** Pas de shell veut dire pas de `docker exec -it app sh`. La première fois que quelqu'un a eu besoin d'inspecter un conteneur en cours d'exécution en staging, il n'a pas pu. La réponse qui est restée : `docker debug` (Docker Desktop) ou un sidecar avec un shell qui partage l'espace de noms des processus, pour l'occasion rare. En pratique, ce que les gens voulaient d'`exec`, c'était lire une configuration ou vérifier une variable d'environnement, et les deux trouvent une meilleure réponse dans [le mode profond de la route de health](/fr/blog/health-checks-that-lie) et dans [les logs structurés](/fr/blog/your-logs-should-not-know-which-cloud). Nous n'avons pas eu besoin d'un shell dans un conteneur de production depuis cinq mois.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flux de build en trois étapes. Étape deps sur node:22 slim : copier le lockfile et les manifestes, pnpm install avec un cache mount, la clé de cache est le lockfile. Étape build : copier la source, next build avec sortie standalone, pnpm deploy prod. Étape runtime sur distroless nodejs22 nonroot : copier uniquement la sortie standalone, static et public ; pas de shell, pas de gestionnaire de paquets, base de 20 Mo. Les flèches montrent que seule la sortie de build passe dans le runtime.">
<defs><marker id="arrD" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="30" width="260" height="170" rx="12" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="150" y="54" text-anchor="middle" fill="#ffd166" font-weight="700">1 · deps · node:22-slim</text><text x="150" y="80" text-anchor="middle" fill="#f1f3ff">copier lockfile + manifestes seulement</text><text x="150" y="100" text-anchor="middle" fill="#f1f3ff">pnpm install --frozen-lockfile</text><text x="150" y="120" text-anchor="middle" fill="#f1f3ff">--mount=type=cache pour le store</text><text x="150" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">clé de cache : le lockfile</text><text x="150" y="168" text-anchor="middle" fill="#9aa3c7" font-size="11">restauré en ~10 s depuis le cache du registre</text>
<line x1="282" y1="115" x2="318" y2="115" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/>
<rect x="320" y="30" width="260" height="170" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="450" y="54" text-anchor="middle" fill="#7b8cff" font-weight="700">2 · build</text><text x="450" y="80" text-anchor="middle" fill="#f1f3ff">copier la source</text><text x="450" y="100" text-anchor="middle" fill="#f1f3ff">next build · output: standalone</text><text x="450" y="120" text-anchor="middle" fill="#f1f3ff">pnpm deploy --prod /out</text><text x="450" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">le traceur ne garde que les modules importés</text><text x="450" y="168" text-anchor="middle" fill="#9aa3c7" font-size="11">420 Mo → 60 Mo de node_modules</text>
<line x1="582" y1="115" x2="618" y2="115" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrD)"/>
<rect x="620" y="30" width="260" height="170" rx="12" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="750" y="54" text-anchor="middle" fill="#4fffb0" font-weight="700">3 · runtime · distroless</text><text x="750" y="80" text-anchor="middle" fill="#f1f3ff">COPY --from=build standalone</text><text x="750" y="100" text-anchor="middle" fill="#f1f3ff">COPY static + public</text><text x="750" y="120" text-anchor="middle" fill="#f1f3ff">CMD ["apps/web/server.js"]</text><text x="750" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">pas de shell · pas d'apt · nonroot · base 20 Mo</text><text x="750" y="168" text-anchor="middle" fill="#9aa3c7" font-size="11">~5 CVE au lieu de ~180</text>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">Seule la dernière étape est livrée. Les deux premières existent pour être mises en cache.</text>
</g>
</svg>
</div>

## Les chiffres

| | Avant | Après |
|---|---|---|
| Taille de l'image | 1,1 Go | 140 Mo |
| Stockage ECR, 30 tags conservés | 33 Go, 3,30 $/mois | 4 Go, 0,40 $/mois |
| Temps de push depuis CodeBuild | 70 s | 9 s |
| Pull à froid App Runner à la montée en charge | 25–40 s | 4–6 s |
| CVE rapportées par le scanner | ~180 | 5 |
| Temps de build, cache chaud, une page modifiée | 4 min | 1 min 40 s |

Le chiffre du pull à froid est celui que les utilisateurs ont senti. La montée en charge sous un pic de trafic voulait dire une demi-minute avant que la nouvelle instance puisse servir ; c'est maintenant sous les dix secondes, ce qui fait la différence entre un pic absorbé et un pic qui produit une page de 503.

## Ce que vous abandonnez

- **Pas de shell en production.** Couvert plus haut. C'est une qualité jusqu'au jour où vous en voulez un, et alors vous voulez un sidecar.
- **Les modules natifs doivent être précompilés.** Tout ce qui a une étape `node-gyp` compile à l'étape 1 contre la glibc de Debian, que distroless utilise aussi, donc ça marche. Si vous allez plus loin vers une base Alpine ou musl, ça ne marchera pas. Nous sommes restés sur distroless basé sur Debian pour cette raison.
- **`sharp` et ses semblables ont besoin de leurs bibliothèques partagées.** Le traceur standalone copie le binaire `.node` mais pas `libvips`. `sharp` embarque la sienne depuis la version 0.33 ; les versions plus anciennes ont besoin des bibliothèques copiées depuis l'étape de build. Vérifiez la première requête qui touche des images après le changement.
- **L'utilisateur `nonroot` ne peut pas se lier aux ports sous 1024.** Utilisez 3000 et laissez la plateforme le mapper. Si quelque chose insiste sur le 80, c'est le travail de la plateforme, pas du conteneur.

## La version courte

Base slim pour construire, distroless pour exécuter, `output: 'standalone'` pour que le traceur fasse l'élagage, un `.dockerignore` qui empêche la source et `.git` d'entrer dans le contexte de build, et une couche d'installation indexée sur le lockfile que le cache du registre restaure. De 1,1 Go à 140 Mo, de 40 secondes de pull à froid à 5, de 180 CVE à 5.

Si votre image Node dépasse 500 Mo, [nous pouvons la ramener sous 200 en une journée](/contact), et la journée se rembourse généralement dès le premier mois de temps de pull.
