Chapitre 12.2 — Dockerfile pour Next.js
⏱️ TL;DR — Un
Dockerfiledécrit, étape par étape, comment construire l’image de ton app. Écrit naïvement, il produit une image énorme (tout le repo,node_modulesde dev, cache de build) et peu sûre (l’app tourne en root). La bonne recette pour Next.js : un build multi-stage — un stage qui installe les deps, un qui builde, un stage final minimal qui ne garde que la sortieoutput: 'standalone'. Résultat : une image de quelques dizaines de Mo, qui tourne en utilisateur non-root, et dont le cache de layers rend les rebuilds quasi instantanés quand seul le code change. Ajoute un.dockerignore(surtout pour exclurenode_modules,.nextet.env) et tu as une image de prod propre. Ce chapitre te donne leDockerfilecomplet, commenté.
🎯 Objectifs
- Comprendre la construction par couches (layers) et le cache de build.
- Écrire un
Dockerfilemulti-stage (deps → build → runner) pour Next.js. - Exploiter
output: 'standalone'pour une image finale minimale. - Rédiger un
.dockerignorecorrect (et savoir pourquoi.enven fait partie). - Faire tourner l’app en utilisateur non-root dans l’image.
- Construire (
docker build) et lancer l’image, et ordonner lesCOPYpour un cache efficace.
Le principe : une image se construit par couches
Chaque instruction d’un Dockerfile (FROM, COPY, RUN…) crée une couche (layer) empilée sur la précédente. Docker met en cache chaque couche : au rebuild, tant qu’une instruction et tout ce qui la précède n’ont pas changé, Docker réutilise le cache au lieu de refaire le travail. Dès qu’une couche change, elle et toutes les suivantes sont reconstruites.
Cette mécanique dicte l’ordre des instructions. La règle d’or : ce qui change rarement en haut, ce qui change souvent en bas. Tes dépendances (package.json) changent rarement ; ton code change à chaque commit. Donc on copie et installe les deps d’abord, le code ensuite. Ainsi, un simple changement de code ne réinvalide pas l’installation des dépendances — le rebuild est quasi instantané.
⚠️ Piège — Le
Dockerfilenaïf faitCOPY . .puisRUN npm ci. Conséquence : la moindre modification d’un fichier de code invalide la coucheCOPY, donc réinstalle toutes les dépendances à chaque build. Sur un gros projet, c’est plusieurs minutes perdues à chaque fois. Copie d’abordpackage*.json, lancenpm ci, puis copie le reste. Le cache fera le reste.
La stratégie multi-stage pour Next.js
Un build multi-stage, c’est plusieurs FROM dans un même Dockerfile. Les premiers stages construisent (ils ont besoin de tout : deps de dev, compilateur, cache). Le dernier stage repart d’une base propre et ne copie sélectivement que le strict nécessaire depuis les stages précédents. Les stages intermédiaires sont jetés : ils ne pèsent rien dans l’image finale.
Pour Next.js, le levier décisif est l’option output: 'standalone' dans next.config.js. Elle demande à Next de produire, à la fin du build, un dossier .next/standalone autonome : un serveur Node minimal avec uniquement les dépendances réellement utilisées au runtime (traçées automatiquement), au lieu de tout node_modules. C’est exactement ce qu’il faut copier dans le stage final.
💡 Réflexe — Active
output: 'standalone'dansnext.config.jsavant d’écrire tonDockerfile. C’est la différence entre une image finale de ~150 Mo (avec toutnode_modules) et ~40 Mo (standalone). Sans elle, tu traînes des centaines de Mo de dépendances inutiles en prod.
Le Dockerfile complet, commenté
Voici la recette de référence, en trois stages : deps (installe les dépendances), build (construit l’app), runner (l’image finale, minimale et non-root).
# syntax=docker/dockerfile:1
# ─────────────────────────────────────────────────────────────
# Stage 1 — deps : installe UNIQUEMENT les dépendances
# Isolé pour que le cache de cette étape ne dépende que de package*.json
# ─────────────────────────────────────────────────────────────
FROM node:22-alpine AS deps
WORKDIR /app
# On copie d'abord les manifestes seuls : cette couche ne change
# que si package.json / package-lock.json changent.
COPY package.json package-lock.json ./
# 'npm ci' installe EXACTEMENT le lockfile (reproductible), contrairement
# à 'npm install' qui peut faire évoluer les versions.
RUN npm ci
# ─────────────────────────────────────────────────────────────
# Stage 2 — build : construit l'application Next.js
# ─────────────────────────────────────────────────────────────
FROM node:22-alpine AS build
WORKDIR /app
# On récupère les node_modules déjà installés du stage 'deps' (cache).
COPY --from=deps /app/node_modules ./node_modules
# MAINTENANT seulement on copie le code source (change souvent → tout en bas).
COPY . .
# 'output: standalone' dans next.config.js produit .next/standalone
RUN npm run build
# ─────────────────────────────────────────────────────────────
# Stage 3 — runner : l'image FINALE, minimale
# Repart d'une base propre : rien du stage build ne traîne, sauf ce qu'on copie.
# ─────────────────────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
# Mode production : désactive les avertissements de dev, optimise Node.
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# On copie UNIQUEMENT ce qui sert au runtime :
# - public/ : les assets statiques (images, favicon…)
# - .next/standalone : le serveur Node autonome + deps minimales
# - .next/static : les fichiers buildés (JS/CSS) servis au client
COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
# L'image officielle 'node' fournit déjà un utilisateur non-root 'node'.
# On bascule dessus : l'app ne tournera JAMAIS en root.
USER node
# Documente le port applicatif (informatif ; la vraie publication se fait au run).
EXPOSE 3000
# standalone génère un server.js à la racine : c'est le point d'entrée.
CMD ["node", "server.js"]Quelques points qui méritent qu’on s’arrête :
FROM node:22-alpine— la variantealpineest une base Linux ultra-minimale (quelques Mo), idéale pour une image de prod. Le22désigne une ligne LTS de Node : adapte-le à la version que ton app cible.npm ciplutôt quenpm install— il installe exactement lepackage-lock.json, sans le modifier. C’est le choix pour un build reproductible (même raison qu’en Partie 9).HOSTNAME=0.0.0.0— dans un conteneur, le serveur doit écouter sur0.0.0.0(toutes les interfaces du conteneur), sinon Docker ne peut pas relayer le port depuis l’hôte. Ce n’est pas exposer à Internet : le pare-feu et Nginx restent devant.
💡 Réflexe — Épingle une version majeure de Node (
node:22-alpine), pas le tag mouvantnode:latest. Pour une reproductibilité totale (deux builds identiques à six mois d’écart), va plus loin et épingle un digest (node:22-alpine@sha256:…). Le tag flottantlatestest l’ennemi de la reproductibilité que Docker est censé t’apporter.
Le .dockerignore : indispensable
Quand tu fais docker build ., Docker envoie tout le dossier courant au moteur de build (le contexte). Sans filtre, ça inclut node_modules (des centaines de Mo inutiles, on les réinstalle dans l’image), le dossier .next de tes builds locaux, le .git… et surtout tes .env pleins de secrets. Le .dockerignore exclut tout ça, exactement comme un .gitignore.
# .dockerignore
node_modules
.next
.git
.gitignore
npm-debug.log
Dockerfile
.dockerignore
# ⚠️ CRUCIAL : ne JAMAIS copier les secrets dans l'image
.env
.env.*🔒 Sécurité — Exclure
.envdu.dockerignoren’est pas optionnel. Une image est un artefact qu’on partage (on la pousse dans un registre, un collègue la tire). Si tes secrets de prod y sont cuits dedans, ils fuient avec l’image — et une image ne « désapprend » pas une couche : même supprimé plus tard, le secret reste dans l’historique des layers. Les variables sensibles s’injectent au runtime (viadocker run -e, unenv_file, ou un gestionnaire de secrets), jamais au build. On y revient au chapitre 12.3.
⚠️ Piège — Oublier le
.dockerignorea un double coût. D’abord la lenteur : copiernode_modulesdans le contexte à chaque build ralentit tout. Ensuite le désastre silencieux : lenode_modulesde ton hôte (parfois compilé pour macOS/Windows) écrase celui de l’image (compilé pour Alpine/Linux) et provoque des erreurs incompréhensibles au démarrage. Un.dockerignorequi exclutnode_modulesrègle les deux d’un coup.
Construire et lancer
Une fois le Dockerfile et le .dockerignore en place :
# Construire l'image et lui donner un nom (tag)
docker build -t formacampus-web .
# -t formacampus-web : le nom (tag) de l'image
# . : le contexte de build (dossier courant)
# La lancer, en publiant le port et en injectant les variables au runtime
docker run -d --name web -p 3000:3000 --env-file .env.production formacampus-web
# -d : en arrière-plan
# -p 3000:3000 : publie le 3000 du conteneur sur le 3000 de l'hôte
# --env-file : injecte les variables AU RUNTIME (pas au build)
# Vérifier
docker logs -f web # les logs de démarrage de Next
curl -I http://localhost:3000 # doit répondre 200🐚 Au terminal — Après un
docker build, inspecte le poids obtenu avecdocker images formacampus-web. Si tu vois plusieurs centaines de Mo, c’est le signal queoutput: 'standalone'n’est pas actif ou que le stage runner copie trop : tu compares avec/sans, tu vois l’écart, tu comprends le gain du multi-stage sur des chiffres réels.
🧭 Sur FormaCampus — Le front Next de FormaCampus utilise ce
Dockerfilemot pour mot : basenode:22-alpine, multi-stage, sortie standalone,USER node. L’image finale pèse ~45 Mo au lieu des ~600 Mo qu’aurait donnés unCOPY . .naïf. Le.dockerignoreexclut le.env.productiondu serveur (les secrets de la base et des clés API sont injectés au runtime parcompose, chapitre suivant). L’API Symfony aura son propreDockerfile(base PHP-FPM,composer install --no-dev, cache réchauffé) construit sur les mêmes principes : multi-stage, non-root, contexte filtré.
✏️ Exercices
Exercice 1 — Répare l’ordre. On te donne ce début de Dockerfile :
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run buildPourquoi ce cache est-il inefficace ? Réécris les quatre premières lignes utiles pour un cache optimal.
✅ Solution
Le COPY . . avant npm ci fait que toute modification de code invalide la couche de copie, donc réinstalle toutes les dépendances à chaque build. On copie d’abord les manifestes, on installe, puis on copie le reste :
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run buildAinsi npm ci n’est refait que si package*.json change, pas à chaque changement de code.
Exercice 2 — Le secret cuit dans l’image. Un dev met COPY .env . dans son Dockerfile « pour que l’app ait ses variables ». Quel est le problème, et quelle est la bonne pratique ?
✅ Solution
Le .env (secrets de prod) est cuit dans une couche de l’image. Comme l’image se pousse dans un registre et se partage, les secrets fuient avec elle — et restent dans l’historique des layers même si on les supprime plus tard. Bonne pratique : mettre .env dans le .dockerignore, et injecter les variables au runtime (docker run --env-file, env_file: de compose, ou un gestionnaire de secrets). Les secrets ne doivent jamais entrer dans l’image au build.
Exercice 3 — Pourquoi non-root ? Le stage runner finit par USER node. Qu’est-ce que ça change, et pourquoi est-ce important même si le conteneur est « isolé » ?
✅ Solution
USER node fait tourner le processus applicatif en utilisateur non privilégié à l’intérieur du conteneur, au lieu de root (le défaut). C’est de la défense en profondeur : si une faille de l’app permet une exécution de code, l’attaquant est root dans le conteneur par défaut — et, combiné à une faille d’évasion ou à un mauvais montage, ça facilite la compromission de l’hôte. Un processus non-root réduit fortement la surface. Les images officielles node fournissent déjà l’utilisateur node prêt à l’emploi.
🧠 Quiz de révision
1. Qu’est-ce qu’un build multi-stage et qu’apporte-t-il ?
Plusieurs FROM dans un même Dockerfile : des stages qui construisent (deps, build) et un stage final qui repart propre et ne copie que le nécessaire. Les stages intermédiaires sont jetés → image finale minimale, sans outils ni caches de build.
2. Pourquoi copier package*.json avant le reste du code ?
package*.json avant le reste du code ?Pour le cache de layers : l’installation des dépendances (npm ci) ne dépend alors que des manifestes. Un changement de code n’invalide pas cette couche, donc pas de réinstallation inutile — les rebuilds deviennent quasi instantanés.
3. À quoi sert output: 'standalone' pour Next.js en Docker ?
output: 'standalone' pour Next.js en Docker ?Il fait produire par Next un dossier .next/standalone autonome : un serveur Node minimal avec uniquement les dépendances utiles au runtime. Le stage runner ne copie que ça (+ public et .next/static), d’où une image finale très légère.
4. Cite trois entrées indispensables d’un .dockerignore pour Next.js, et pourquoi.
.dockerignore pour Next.js, et pourquoi.node_modules (réinstallé dans l’image, évite lenteur et incompatibilités d’OS), .next (build local inutile), et .env (secrets qui ne doivent jamais entrer dans une image partagée). On y ajoute souvent .git.
5. Que fait USER node et pourquoi le mettre ?
USER node et pourquoi le mettre ?Il fait tourner l’app en utilisateur non-root dans le conteneur. C’est de la défense en profondeur : en cas de faille applicative, l’attaquant n’est pas root, ce qui réduit le risque de compromission de l’hôte. L’utilisateur node est fourni par l’image officielle.
Chapitre suivant : docker compose — orchestrer front, API et base d’une seule commande.