Mettre une application Next.js dans une image Docker de 1 Go qui met trente secondes à démarrer, c'est rater l'intérêt du conteneur. Un Docker Next.js bien construit tient sous 200 Mo, démarre en moins d'une seconde et ne contient ni code source, ni dépendances de développement, ni outils de build.
Le problème vient presque toujours du même endroit : un Dockerfile naïf qui copie tout le projet, lance npm install puis next build, et conserve l'intégralité du résultat dans l'image finale. Vous embarquez alors node_modules au complet — souvent 700 Mo — plus le cache de build, les sources TypeScript et le binaire de compilation.
Cet article montre comment construire une image de production propre avec un build multi-stage et l'output standalone de Next.js. Vous repartez avec un Dockerfile prêt à coller, les pièges de configuration qui cassent le démarrage, et la gestion correcte des variables d'environnement entre build et runtime.
Pourquoi output: standalone change tout
Par défaut, faire tourner Next.js en production suppose d'avoir tout le projet sous la main : node_modules, .next, package.json, et de lancer next start. C'est lourd et inutile dans un conteneur.
Le mode standalone résout ce problème à la racine. Vous l'activez par une ligne dans next.config.ts :
// Next.js trace les imports réellement utilisés par le serveur
// et ne copie que ces fichiers : node_modules passe de ~700 Mo à ~50 Mo
const nextConfig = {
output: 'standalone',
}
export default nextConfig
Au build, Next.js analyse le graphe de dépendances avec @vercel/nft et génère un dossier .next/standalone. Ce dossier contient un server.js autonome et uniquement les modules node_modules réellement chargés à l'exécution. Plus besoin de next start ni du package.json : vous lancez node server.js.
Concrètement, c'est ce qui fait passer une image de 1 Go à 200 Mo. Sans standalone, aucune optimisation multi-stage ne vous sauvera, parce que le runtime exigera quand même l'arbre node_modules complet. C'est la fondation de tout le reste.
Anatomie d'un build multi-stage
Un build multi-stage sépare la fabrication de l'exécution. Chaque FROM démarre une étape isolée, et seules les étapes finales finissent dans l'image livrée. On utilise trois étapes : installation des dépendances, build, puis runtime minimal.
# Étape 1 — dépendances isolées pour profiter du cache de couche Docker
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Étape 2 — build de l'application
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Étape 3 — runtime, ne contient QUE le strict nécessaire
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
L'intérêt n'est pas cosmétique. L'étape deps est mise en cache tant que package-lock.json ne change pas : vos rebuilds ne réinstallent pas les dépendances à chaque commit. Et surtout, l'étape runner n'hérite de rien — ni du code source, ni des devDependencies, ni du cache de build. Elle ne reçoit que les trois dossiers copiés explicitement.
Construire l'image Docker Next.js étape par étape
Le passage de l'étape builder à l'étape runner est l'endroit où la plupart des erreurs se logent. Trois COPY sont obligatoires et leur ordre de dossiers compte.
Le premier copie .next/standalone à la racine : il amène server.js et le node_modules réduit. Le deuxième copie .next/static, qui n'est pas inclus dans le standalone — Next.js part du principe qu'un CDN sert ces fichiers, donc il faut les rapatrier manuellement sinon votre CSS et votre JS renvoient des 404. Le troisième copie public, pour les images et fichiers statiques.
Oubliez .next/static et l'application démarre sans erreur mais s'affiche sans styles. C'est le bug numéro un des images Docker Next.js, parce que rien dans les logs ne le signale au démarrage.
Une fois ces trois copies en place, node server.js écoute sur le port 3000 et sert l'application complète, server components et route handlers compris.
Réduire encore la taille : Alpine et .dockerignore
L'image de base pèse lourd dans le total. node:22 complet fait environ 1,1 Go ; node:22-alpine tombe à 130 Mo. Alpine utilise musl libc au lieu de glibc, ce qui suffit pour la quasi-totalité des projets Next.js. Si vous compilez des binaires natifs qui dépendent de glibc, basculez sur node:22-slim plutôt que de revenir à l'image complète.
Le second levier est le .dockerignore. Sans lui, la commande COPY . . de l'étape builder envoie votre node_modules local, votre dossier .next, vos logs et votre .git dans le contexte de build. Résultat : des transferts inutiles et un cache invalidé en permanence.
node_modules
.next
.git
.env*.local
npm-debug.log
Dockerfile
.dockerignore
Ces deux réglages combinés font la différence entre une image de 500 Mo et une image de 180 Mo. Dans une logique de monorepo, le .dockerignore devient encore plus critique pour ne pas embarquer les autres packages du workspace — un sujet que nous détaillons dans notre guide du monorepo Next.js avec Turborepo.
Variables d'environnement : build-time contre runtime
C'est la confusion qui casse le plus de déploiements. Next.js distingue deux moments où les variables interviennent, et les mélanger produit des bugs difficiles à diagnostiquer.
Les variables préfixées NEXT_PUBLIC_ sont inlinées au build. Leur valeur est gravée dans le bundle JavaScript pendant next build. Les changer au runtime n'a aucun effet : il faut reconstruire l'image. À l'inverse, les variables serveur classiques — chaînes de connexion, clés d'API — sont lues à l'exécution via process.env et peuvent être injectées au lancement du conteneur.
# Variable serveur : lue au runtime, injectable via docker run -e
ENV DATABASE_URL=""
# Variable publique : doit exister AU BUILD, pas au runtime
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
La conséquence pratique est forte : une même image ne peut pas servir staging et production si ces deux environnements ont des NEXT_PUBLIC_ différents. Vous devez soit construire une image par environnement, soit éviter les variables publiques côté client et passer par des route handlers qui lisent la configuration serveur au runtime.
Sécurité et signaux : utilisateur non-root et arrêt propre
Une image de production ne tourne jamais en root. Ajoutez un utilisateur dédié dans l'étape runner et donnez-lui la propriété des fichiers copiés.
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
USER nextjs
Au-delà de la sécurité, deux points comptent en orchestration. D'abord, le serveur standalone gère correctement le signal SIGTERM envoyé par Kubernetes ou Docker à l'arrêt, ce qui permet une terminaison propre des requêtes en cours. Ensuite, ajoutez un HEALTHCHECK ou exposez une route /api/health que l'orchestrateur interroge pour décider si le conteneur est prêt à recevoir du trafic.
Ces réglages ne réduisent pas la taille de l'image mais conditionnent un déploiement fiable. Un conteneur qui ne répond pas aux health checks sera tué en boucle par Kubernetes, et un arrêt brutal sans gestion de SIGTERM coupe les requêtes au milieu d'un rolling update.
En pratique
Un Docker Next.js de production repose sur trois décisions prises ensemble : activer output: standalone, séparer build et runtime en multi-stage, et partir d'une base Alpine nettoyée par un .dockerignore strict. Prises isolément, chacune aide peu ; combinées, elles font passer une image de plus d'un gigaoctet à moins de 200 Mo, avec un démarrage quasi instantané.
Le reste relève de la rigueur de production : un utilisateur non-root, une route de health check, et une discipline claire sur les variables d'environnement pour qu'une même image traverse vos environnements sans rebuild. Cette dernière contrainte se marie directement avec une stratégie de déploiement blue-green ou canary, où l'on promeut un artefact identique d'un environnement à l'autre. Pensez aussi à la cohérence avec votre stratégie de cache Next.js, car le comportement de l'ISR change selon que vous montez un volume persistant ou non sur votre conteneur.
Chez Kreio, agence Next.js basée à Évreux (Normandie), on applique ces patterns sur des projets clients en production. Besoin d'un audit d'image ou d'un renfort tech sur votre pipeline ? Parlons-en.