Apogée Consult
Retour au blog
Mathieu Ponton
Mathieu PontonCo-Founder & ingénieur logiciel

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="..." myapp

Les 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-alpine

Avec 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_Store

Tailles 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:latest

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

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
Dockerfile Next.js production : image minimale | Apogée Consult