Skip to Content
Serveur LinuxPartie 12 — Docker & conteneurs12.2 — Dockerfile pour Next.js

Chapitre 12.2 — Dockerfile pour Next.js

⏱️ TL;DR — Un Dockerfile décrit, étape par étape, comment construire l’image de ton app. Écrit naïvement, il produit une image énorme (tout le repo, node_modules de 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 sortie output: '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 exclure node_modules, .next et .env) et tu as une image de prod propre. Ce chapitre te donne le Dockerfile complet, commenté.

🎯 Objectifs

  • Comprendre la construction par couches (layers) et le cache de build.
  • Écrire un Dockerfile multi-stage (deps → build → runner) pour Next.js.
  • Exploiter output: 'standalone' pour une image finale minimale.
  • Rédiger un .dockerignore correct (et savoir pourquoi .env en fait partie).
  • Faire tourner l’app en utilisateur non-root dans l’image.
  • Construire (docker build) et lancer l’image, et ordonner les COPY pour 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 Dockerfile naïf fait COPY . . puis RUN npm ci. Conséquence : la moindre modification d’un fichier de code invalide la couche COPY, donc réinstalle toutes les dépendances à chaque build. Sur un gros projet, c’est plusieurs minutes perdues à chaque fois. Copie d’abord package*.json, lance npm 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' dans next.config.js avant d’écrire ton Dockerfile. C’est la différence entre une image finale de ~150 Mo (avec tout node_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 variante alpine est une base Linux ultra-minimale (quelques Mo), idéale pour une image de prod. Le 22 désigne une ligne LTS de Node : adapte-le à la version que ton app cible.
  • npm ci plutôt que npm install — il installe exactement le package-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 sur 0.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 mouvant node: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 flottant latest est 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 .env du .dockerignore n’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 (via docker run -e, un env_file, ou un gestionnaire de secrets), jamais au build. On y revient au chapitre 12.3.

⚠️ Piège — Oublier le .dockerignore a un double coût. D’abord la lenteur : copier node_modules dans le contexte à chaque build ralentit tout. Ensuite le désastre silencieux : le node_modules de 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 .dockerignore qui exclut node_modules rè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 avec docker images formacampus-web. Si tu vois plusieurs centaines de Mo, c’est le signal que output: '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 Dockerfile mot pour mot : base node:22-alpine, multi-stage, sortie standalone, USER node. L’image finale pèse ~45 Mo au lieu des ~600 Mo qu’aurait donnés un COPY . . naïf. Le .dockerignore exclut le .env.production du serveur (les secrets de la base et des clés API sont injectés au runtime par compose, chapitre suivant). L’API Symfony aura son propre Dockerfile (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 build

Pourquoi 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 build

Ainsi 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 ?

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 ?

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.

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 ?

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.

Last updated on