
Je suis Mathieu Ponton, Co-Founder & ingénieur logiciel chez Apogée Consult à Lyon. Ingénieur diplômé de Polytech Lyon (Informatique), j'ai fait trois ans en apprentissage, partagés entre la Métropole de Lyon (inclusion numérique avec Res'in et sobriété énergétique avec Écolyo) et Superwyze, une startup medtech (POCs, dont certains aujourd'hui industrialisés, et travail sur des codebases existantes). J'ai livré plus de 10 projets en production (web, mobile et IA / RAG) pour des PME, startups et organisations publiques.
Docker pour Next.js : l'image minimale qu'on utilise en production, expliquée ligne par ligne
- devops
Notre image Next.js de production pèse 180 Mo et démarre en moins de 2 secondes. Voici le Dockerfile complet, chaque ligne justifiée, avec les erreurs classiques à éviter.
Docker pour Next.js : l'image minimale qu'on utilise en production, expliquée ligne par ligne
Une image Next.js non optimisée pèse facilement 1,2 Go. La même application avec une construction multi-stage et le mode standalone descend autour de 150-200 Mo. C'est la différence entre un cold start de 15 secondes et un démarrage en moins de 2 secondes sur un VPS avec peu de RAM. Voici exactement ce qu'on fait et pourquoi.
Le Dockerfile complet
# ───────────────────────────────────────────
# Étape 1 : installation des dépendances
# ───────────────────────────────────────────
FROM node:22-alpine AS deps
# libc6-compat est nécessaire sur Alpine pour certains
# modules natifs (sharp, argon2, sqlite3)
RUN apk add --no-cache libc6-compat
WORKDIR /app
# On copie uniquement les fichiers de lockfile d'abord.
# Si package.json et le lockfile n'ont pas changé,
# Docker réutilise le cache de cette couche.
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
# ───────────────────────────────────────────
# Étape 2 : build de l'application
# ───────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
# On copie les node_modules de l'étape deps
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Variables d'environnement publiques (NEXT_PUBLIC_*)
# intégrées au build. Les secrets runtime ne sont PAS ici.
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
# next build lit NEXT_TELEMETRY_DISABLED pour désactiver
# l'envoi de métriques anonymes. On le désactive en build.
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ───────────────────────────────────────────
# Étape 3 : image de production
# ───────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# On crée un groupe et un utilisateur non-root dédiés.
# Ne jamais faire tourner Node.js en production comme root.
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Le mode standalone copie uniquement ce dont Next.js a besoin
# pour tourner : pas de node_modules complets, juste les
# dépendances transitives réellement utilisées au runtime.
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# On donne la propriété des fichiers à l'utilisateur nextjs
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Le mode standalone génère un fichier server.js à la racine
CMD ["node", "server.js"]Activer le mode standalone dans next.config
Le mode standalone est désactivé par défaut. Il faut l'activer explicitement :
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
export default nextConfig;Sans cette option, le build produit un dossier .next qui nécessite node_modules complet pour tourner. Avec standalone, Next.js trace les dépendances réellement utilisées et copie uniquement celles-là dans .next/standalone/node_modules. C'est la source de la réduction de taille la plus significative.
Les erreurs classiques
Copier node_modules depuis le builder
# Mauvais, copie node_modules de dev (~800 Mo)
COPY --from=builder /app/node_modules ./node_modules
# Correct, le standalone a ses propres node_modules tracés
COPY --from=builder /app/.next/standalone ./Le dossier standalone contient un sous-dossier node_modules avec uniquement les modules nécessaires au runtime. En copiant node_modules depuis le builder, on inclut toutes les dev dependencies (TypeScript, ESLint, Jest, etc.).
Mettre des secrets dans les ARG de build
# Dangereux, les ARG de build sont dans les métadonnées de l'image
ARG DATABASE_URL
ENV DATABASE_URL=${DATABASE_URL}Les ARG Docker sont visibles dans l'historique de l'image via docker history. Les secrets runtime (DATABASE_URL, clés API) doivent être passés au container via des variables d'environnement au lancement, pas au build :
docker run -e DATABASE_URL="postgres://..." -e SECRET_KEY="..." myappLes seules variables qui doivent être dans le build sont les NEXT_PUBLIC_*, car elles sont inlinées dans le bundle JavaScript côté client.
Ne pas fixer la version de l'image base
# Déconseillé, version flottante, build non-reproductible
FROM node:alpine
# Correct, version de Node.js explicite
FROM node:22-alpineAvec une version flottante, un rebuild dans 6 mois peut utiliser une version de Node.js différente. L'image 22-alpine est suffisamment stable. On peut aller jusqu'à fixer le digest SHA256 pour des environnements très contraints, mais c'est rarement nécessaire.
Oublier .dockerignore
Sans .dockerignore, COPY . . copie node_modules, .next, .git, et tous les fichiers de config locaux dans l'image builder. Ça ralentit le build et peut exposer des fichiers sensibles :
# .dockerignore
node_modules
.next
.git
.env
.env.local
*.log
coverage
.DS_StoreTailles réelles constatées
Sur un projet Next.js 15 avec App Router, environ 80 composants, Tailwind v4, et quelques packages de traitement (sharp pour les images) :
- Image sans optimisation (copie directe) : 1,18 Go
- Image multi-stage sans standalone : 620 Mo
- Image multi-stage avec standalone : 178 Mo
Le démarrage du container sur un VPS avec 2 vCPU et 4 Go de RAM :
- Image 1,18 Go : 14 secondes (pull + start)
- Image 178 Mo : 1,8 secondes
Sur un pipeline de déploiement avec pull de l'image à chaque déploiement, la différence est sensible.
Build multi-plateforme pour Apple Silicon
Si vous développez sur Mac ARM et déployez sur Linux x86 :
docker buildx build \
--platform linux/amd64 \
--build-arg NEXT_PUBLIC_APP_URL=https://app.example.com \
-t registry.example.com/myapp:latest \
--push .Sans --platform linux/amd64, l'image buildée sur Mac sera ARM64 et ne tournera pas sur la plupart des VPS. La cross-compilation via buildx est plus lente mais produit une image compatible.
Lancement en production
docker run -d \
--name myapp \
--restart unless-stopped \
-p 3000:3000 \
-e DATABASE_URL="${DATABASE_URL}" \
-e AUTH_SECRET="${AUTH_SECRET}" \
-e NEXT_PUBLIC_APP_URL="https://app.example.com" \
registry.example.com/myapp:latestEn production, nous passons devant Traefik ou Nginx pour la terminaison TLS et le routing. Le container Next.js ne gère pas le TLS directement.
Le mode standalone est la décision la plus impactante. Si vous containerisez Next.js sans l'activer, vous embarquez plusieurs centaines de mégaoctets de dépendances inutiles. Y a-t-il des cas dans votre stack où vous avez dû ajuster le Dockerfile pour des modules natifs spécifiques ?