écrits/tutorial/2026/07
Tutorial28 juil. 2026·35 min

Déployer Next.js sur Kubernetes avec Helm 4 et Argo CD GitOps

Un guide de production pour faire tourner Next.js 16 sur Kubernetes : une image Docker minimale en mode standalone, un chart Helm 4 avec server-side apply, sondes de santé et autoscaling, puis livraison continue pilotée par Argo CD 3.3 et GitOps.

Pourquoi mettre Next.js sur Kubernetes ?

La plupart des applications Next.js n'ont pas besoin de Kubernetes. Vercel, un simple VPS avec Kamal, ou Cloudflare Workers vous serviront mieux et coûteront moins cher. Kubernetes ne justifie sa complexité que dans quelques situations précises : vous exploitez déjà d'autres services sur un cluster et voulez une seule logique de déploiement, vous avez besoin d'un autoscaling horizontal lié à la pression réelle des requêtes, vous travaillez sous des règles de résidence des données qui excluent les plateformes managées, ou vous avez besoin de plusieurs environnements dont l'identité est démontrable.

Si c'est votre cas, la voie naïve — un kubectl apply -f sur un dossier de YAML écrits à la main — s'effondre en quelques semaines. Les manifestes divergent entre environnements, plus personne ne sait quel commit a produit le Pod en cours d'exécution, et les rollbacks tournent à l'archéologie.

Ce tutoriel construit la version disciplinée. À la fin, vous aurez :

  • Une image Next.js 16 construite depuis output: "standalone", généralement 30 à 60 % plus légère qu'un build naïf
  • Un chart Helm 4 réutilisable avec values-staging.yaml et values-production.yaml
  • Des sondes liveness, readiness et startup branchées sur un vrai endpoint de santé
  • Un HorizontalPodAutoscaler et un PodDisruptionBudget pour que la montée en charge et les drains de nœuds ne provoquent aucune interruption
  • Un Ingress avec TLS via cert-manager
  • Argo CD 3.3 réconciliant en continu le cluster avec Git, avec synchronisation automatique, auto-réparation et rollback en une commande

Le schéma s'applique à toute application Node.js conteneurisée. Next.js n'est que l'exemple concret.

Prérequis

Vous devez être à l'aise avec Docker et avoir déjà mis une application Next.js en production quelque part. Il vous faut :

  • Node.js 20+ et une application Next.js 16 (l'exemple App Router ci-dessous suppose la version 16.x)
  • Docker 27+ pour construire les images
  • Un cluster Kubernetes en version 1.33 ou plus récente. En local, kind ou le cluster intégré à Docker Desktop suffit. En production, n'importe quelle offre managée (DOKS, EKS, GKE, AKS) convient.
  • kubectl 1.34+, configuré sur votre cluster
  • Helm 4.2+ — ce tutoriel utilise des fonctionnalités Helm 4 qui n'existent pas en Helm 3
  • Un registre de conteneurs où vous pouvez pousser (GHCR, Docker Hub, ou celui de votre fournisseur cloud)
  • Un dépôt Git que votre cluster peut lire — il devient la source de vérité

Vérifiez votre outillage :

kubectl version --output=yaml | grep gitVersion
helm version --short
docker --version

Vous devez voir une version de Helm en v4.2.x ou plus récente. Si vous obtenez v3.x, mettez à jour avant de continuer — plusieurs étapes ci-dessous reposent sur le comportement de Helm 4.

Helm 4 a renommé --force en --force-replace et n'accepte plus un exécutable arbitraire pour --post-renderer. Si vous migrez une installation Helm 3 existante, auditez vos scripts CI sur ces deux points avant la mise à jour.

Ce que vous allez construire

Dépôt Git (source de vérité)
        │
        │  Argo CD surveille ce chemin
        ▼
charts/nextjs-app/           ← chart Helm
  ├── Chart.yaml
  ├── values.yaml            ← valeurs par défaut
  ├── values-staging.yaml
  ├── values-production.yaml
  └── templates/
      ├── deployment.yaml
      ├── service.yaml
      ├── ingress.yaml
      ├── hpa.yaml
      ├── pdb.yaml
      └── configmap.yaml
        │
        │  Argo CD rend + applique
        ▼
Cluster Kubernetes
  Ingress → Service → Deployment (3+ Pods, autoscalés)

Un push sur main met à jour le tag d'image dans Git. Argo CD le détecte en quelques secondes, rend le chart, applique le diff et attend que le déploiement soit sain. Aucun runner de CI ne détient jamais les identifiants du cluster.

Étape 1 : construire une image Next.js standalone

La plus grosse erreur lorsqu'on conteneurise Next.js est d'embarquer l'arbre node_modules complet. Next.js sait tracer exactement quels fichiers le serveur importe réellement et ne copier que ceux-là dans .next/standalone.

Activez-le dans next.config.ts :

import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  // Trace les imports serveur et produit un dossier .next/standalone autonome
  output: "standalone",
 
  // Kubernetes termine le TLS au niveau de l'Ingress : l'app parle donc HTTP.
  poweredByHeader: false,
 
  experimental: {
    // Bundle serveur plus léger en n'intégrant pas les source maps en prod
    serverSourceMaps: false,
  },
};
 
export default nextConfig;

Passons au Dockerfile. Trois étapes gardent le cache de couches propre et l'image finale légère :

# syntax=docker/dockerfile:1.7
 
# ---- Étape 1 : dépendances ----------------------------------------------
FROM node:20-alpine AS deps
WORKDIR /app
 
# On copie d'abord le lockfile pour que cette couche reste en cache
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
 
# ---- Étape 2 : build -----------------------------------------------------
FROM node:20-alpine AS builder
WORKDIR /app
 
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
# Les variables publiques doivent être présentes ici — elles sont inlinées
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN corepack enable && pnpm build
 
# ---- Étape 3 : exécution -------------------------------------------------
FROM node:20-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
 
# Ne jamais tourner en root. Kubernetes l'imposera aussi, mais défense en profondeur.
RUN addgroup --system --gid 1001 nodejs \
 && adduser  --system --uid 1001 nextjs
 
# La sortie standalone contient déjà un node_modules minimal et server.js
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
 
USER nextjs
EXPOSE 3000
 
CMD ["node", "server.js"]

Deux détails font trébucher beaucoup de monde :

HOSTNAME=0.0.0.0 est obligatoire. Le serveur standalone écoute sur localhost par défaut, ce qui signifie que le kubelet ne peut pas l'atteindre et que toutes les sondes readiness échouent. C'est la première cause d'un Pod Next.js bloqué en CrashLoopBackOff sans log exploitable.

Les assets statiques se copient séparément. .next/standalone omet délibérément .next/static et public, car sur Vercel ils sont servis depuis un CDN. Dans un cluster auto-hébergé, vous devez les copier vous-même.

Construisez et poussez :

export REGISTRY=ghcr.io/your-org
export IMAGE=$REGISTRY/nextjs-app
export TAG=$(git rev-parse --short HEAD)
 
docker build \
  --build-arg NEXT_PUBLIC_SITE_URL=https://app.example.com \
  -t $IMAGE:$TAG -t $IMAGE:latest .
 
docker push $IMAGE:$TAG
docker push $IMAGE:latest

Taguez vos images avec le SHA Git, jamais uniquement avec latest. GitOps repose sur le fait que le tag d'image soit un identifiant immuable et traçable — c'est ce qui vous permet de répondre à « quel commit tourne en production en ce moment » sans deviner.

Étape 2 : ajouter un vrai endpoint de santé

Kubernetes doit distinguer trois états : le processus a démarré, le processus est vivant, et le processus est prêt à recevoir du trafic. Une route qui renvoie 200 sans condition ne répond honnêtement à aucun des trois.

Créez app/api/health/route.ts :

import { NextResponse } from "next/server";
import { db } from "@/lib/db";
 
// Ne jamais mettre un health check en cache
export const dynamic = "force-dynamic";
export const revalidate = 0;
 
export async function GET() {
  const checks: Record<string, "ok" | "fail"> = {};
 
  // La liveness concerne le processus. La readiness concerne les dépendances.
  // On rapporte les deux et on laisse la config des sondes décider.
  try {
    await db.execute("SELECT 1");
    checks.database = "ok";
  } catch {
    checks.database = "fail";
  }
 
  const healthy = Object.values(checks).every((v) => v === "ok");
 
  return NextResponse.json(
    {
      status: healthy ? "healthy" : "degraded",
      checks,
      uptime: Math.floor(process.uptime()),
      version: process.env.APP_VERSION ?? "unknown",
    },
    { status: healthy ? 200 : 503 },
  );
}

Puis une route liveness volontairement triviale dans app/api/live/route.ts :

import { NextResponse } from "next/server";
 
export const dynamic = "force-dynamic";
 
// La liveness répond à une seule question : la boucle d'événements tourne-t-elle ?
// Elle ne DOIT PAS interroger la base — une panne de base doit drainer le trafic,
// pas déclencher une boucle de redémarrage infinie sur tous les Pods à la fois.
export async function GET() {
  return NextResponse.json({ status: "alive" });
}

Cette séparation compte plus qu'il n'y paraît. Si votre sonde liveness interroge la base et que celle-ci tombe quatre-vingt-dix secondes, Kubernetes redémarre tous les Pods simultanément, et votre application est toujours hors service au retour de la base — désormais avec des caches froids et une avalanche de reconnexions.

Étape 3 : générer le chart Helm

helm create charts/nextjs-app
rm -rf charts/nextjs-app/templates/tests charts/nextjs-app/templates/serviceaccount.yaml

Remplacez charts/nextjs-app/Chart.yaml :

apiVersion: v2
name: nextjs-app
description: Production Next.js deployment
type: application
 
# version = version du chart, incrémentée quand les templates changent
version: 1.0.0
# appVersion = version applicative par défaut, surchargée par environnement
appVersion: "1.0.0"
 
kubeVersion: ">=1.33.0-0"
 
maintainers:
  - name: Platform Team
    email: platform@example.com

Et les valeurs par défaut dans values.yaml :

replicaCount: 2
 
image:
  repository: ghcr.io/your-org/nextjs-app
  pullPolicy: IfNotPresent
  tag: "" # retombe sur .Chart.AppVersion si vide
 
nameOverride: ""
fullnameOverride: ""
 
service:
  type: ClusterIP
  port: 80
  targetPort: 3000
 
ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/proxy-body-size: 10m
  hosts:
    - host: app.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: nextjs-app-tls
      hosts:
        - app.example.com
 
resources:
  requests:
    cpu: 100m
    memory: 256Mi
  limits:
    # Pas de limite CPU volontairement — voir la note en Étape 5
    memory: 512Mi
 
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80
 
podDisruptionBudget:
  enabled: true
  minAvailable: 1
 
# Configuration d'exécution non sensible
env:
  NODE_ENV: production
  NEXT_TELEMETRY_DISABLED: "1"
 
# Noms de Secrets préexistants à projeter en variables d'environnement
envFromSecrets: []
 
probes:
  startup:
    failureThreshold: 30
    periodSeconds: 2
  liveness:
    path: /api/live
    initialDelaySeconds: 0
    periodSeconds: 10
    failureThreshold: 3
  readiness:
    path: /api/health
    initialDelaySeconds: 0
    periodSeconds: 5
    failureThreshold: 3
 
nodeSelector: {}
tolerations: []
affinity: {}

Étape 4 : écrire le template Deployment

C'est le cœur du chart. Fichier charts/nextjs-app/templates/deployment.yaml :

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "nextjs-app.fullname" . }}
  labels:
    {{- include "nextjs-app.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
 
  revisionHistoryLimit: 5
 
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0    # ne jamais descendre sous la capacité voulue
 
  selector:
    matchLabels:
      {{- include "nextjs-app.selectorLabels" . | nindent 6 }}
 
  template:
    metadata:
      annotations:
        # Redémarre les Pods automatiquement quand la config non secrète change
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
      labels:
        {{- include "nextjs-app.selectorLabels" . | nindent 8 }}
    spec:
      # Laisse aux requêtes en vol le temps de se terminer avant l'arrêt
      terminationGracePeriodSeconds: 30
 
      securityContext:
        runAsNonRoot: true
        runAsUser: 1001
        fsGroup: 1001
        seccompProfile:
          type: RuntimeDefault
 
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
 
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
 
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort }}
              protocol: TCP
 
          envFrom:
            - configMapRef:
                name: {{ include "nextjs-app.fullname" . }}-config
            {{- range .Values.envFromSecrets }}
            - secretRef:
                name: {{ . }}
            {{- end }}
 
          env:
            - name: APP_VERSION
              value: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
 
          # La sonde startup absorbe les démarrages à froid lents
          startupProbe:
            httpGet:
              path: {{ .Values.probes.liveness.path }}
              port: http
            failureThreshold: {{ .Values.probes.startup.failureThreshold }}
            periodSeconds: {{ .Values.probes.startup.periodSeconds }}
 
          livenessProbe:
            httpGet:
              path: {{ .Values.probes.liveness.path }}
              port: http
            periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
            failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
 
          readinessProbe:
            httpGet:
              path: {{ .Values.probes.readiness.path }}
              port: http
            periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
            failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
 
          lifecycle:
            preStop:
              exec:
                # Laisse au contrôleur Ingress le temps de voir le retrait
                # de l'endpoint avant que le processus Node ne s'arrête.
                command: ["sh", "-c", "sleep 5"]
 
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
 
          # readOnlyRootFilesystem impose des montages accessibles en écriture
          # pour tout ce que Next.js écrit à l'exécution
          volumeMounts:
            - name: cache
              mountPath: /app/.next/cache
            - name: tmp
              mountPath: /tmp
 
      volumes:
        - name: cache
          emptyDir: {}
        - name: tmp
          emptyDir: {}
 
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}
 
      # Répartit les Pods entre les nœuds pour qu'une panne n'emporte pas tout
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              {{- include "nextjs-app.selectorLabels" . | nindent 14 }}

Plusieurs choix méritent d'être défendus explicitement.

maxUnavailable: 0 signifie qu'un déploiement ajoute un nouveau Pod avant de retirer l'ancien. Vous échangez un peu de capacité supplémentaire pendant les déploiements contre des releases réellement sans interruption.

Le preStop avec sleep existe parce que la terminaison du Pod et le retrait de l'Endpoint sont concurrents, pas séquentiels. Sans lui, le contrôleur Ingress peut router une requête vers un Pod qui a déjà commencé à s'arrêter. Cinq secondes à ne rien faire sont le correctif standard.

readOnlyRootFilesystem: true est un durcissement réel, mais Next.js écrit dans .next/cache pour l'ISR et l'optimisation d'images. Les montages emptyDir lui donnent où écrire sans ouvrir le reste du système de fichiers.

checksum/config force un redémarrage progressif dès que le ConfigMap change. Sans cette annotation, modifier la config dans Git change le ConfigMap mais laisse les Pods en cours avec les anciennes valeurs chargées — un mode de défaillance particulièrement déroutant.

Étape 5 : Service, autoscaling et budget de disruption

Fichier templates/service.yaml :

apiVersion: v1
kind: Service
metadata:
  name: {{ include "nextjs-app.fullname" . }}
  labels:
    {{- include "nextjs-app.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: http
      protocol: TCP
      name: http
  selector:
    {{- include "nextjs-app.selectorLabels" . | nindent 4 }}

Fichier templates/hpa.yaml :

{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "nextjs-app.fullname" . }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "nextjs-app.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
  behavior:
    scaleUp:
      # Réagit vite aux pics de trafic
      stabilizationWindowSeconds: 30
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
    scaleDown:
      # Décroît lentement pour éviter les oscillations sur trafic irrégulier
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60
{{- end }}

Fichier templates/pdb.yaml :

{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ include "nextjs-app.fullname" . }}
spec:
  minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
  selector:
    matchLabels:
      {{- include "nextjs-app.selectorLabels" . | nindent 6 }}
{{- end }}

Le budget de disruption est facile à oublier et coûteux à omettre. Sans lui, un drain de nœud pendant une mise à jour de cluster peut évincer tous les Pods d'un coup — votre application tombe pendant une maintenance planifiée que vous pensiez sans risque.

Remarquez que values.yaml définit une limite mémoire mais aucune limite CPU. Les limites CPU dans Kubernetes sont appliquées par throttling CFS, ce qui ajoute des pics de latence aux boucles d'événements Node.js même quand le nœud dispose de capacité libre. Définissez des requests CPU pour que l'ordonnanceur place correctement les Pods, et laissez les limites CPU désactivées sauf si vous exécutez des charges réellement non fiables.

Étape 6 : configuration et secrets

templates/configmap.yaml gère les valeurs non sensibles :

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "nextjs-app.fullname" . }}-config
  labels:
    {{- include "nextjs-app.labels" . | nindent 4 }}
data:
  {{- range $key, $value := .Values.env }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}

Les secrets n'ont jamais leur place dans le chart. Référencez plutôt des Secrets déjà présents dans le cluster :

kubectl create secret generic nextjs-app-secrets \
  --namespace production \
  --from-literal=DATABASE_URL='postgresql://...' \
  --from-literal=AUTH_SECRET='...'

Puis dans values-production.yaml :

envFromSecrets:
  - nextjs-app-secrets

Pour un vrai dispositif GitOps, créez ces Secrets via External Secrets Operator (en tirant depuis Vault, AWS Secrets Manager ou Doppler) ou via Sealed Secrets (valeurs chiffrées que l'on peut committer sans risque). Les deux préservent la propriété « tout est dans Git » sans y placer d'identifiants en clair.

Passons aux surcharges par environnement. values-staging.yaml :

replicaCount: 1
 
image:
  tag: main-latest
 
ingress:
  hosts:
    - host: staging.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: nextjs-app-staging-tls
      hosts:
        - staging.example.com
 
autoscaling:
  enabled: false
 
podDisruptionBudget:
  enabled: false
 
resources:
  requests:
    cpu: 50m
    memory: 128Mi
  limits:
    memory: 256Mi
 
env:
  NODE_ENV: production
  NEXT_PUBLIC_ENVIRONMENT: staging
 
envFromSecrets:
  - nextjs-app-secrets-staging

values-production.yaml :

replicaCount: 3
 
autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 20
  targetCPUUtilizationPercentage: 65
 
podDisruptionBudget:
  enabled: true
  minAvailable: 2
 
resources:
  requests:
    cpu: 250m
    memory: 512Mi
  limits:
    memory: 1Gi
 
env:
  NODE_ENV: production
  NEXT_PUBLIC_ENVIRONMENT: production
 
envFromSecrets:
  - nextjs-app-secrets

Étape 7 : valider et installer avec Helm 4

N'appliquez jamais un chart que vous n'avez pas rendu. Lintez d'abord, puis inspectez la sortie :

helm lint charts/nextjs-app -f charts/nextjs-app/values-production.yaml
 
helm template nextjs-app charts/nextjs-app \
  -f charts/nextjs-app/values-production.yaml \
  --set image.tag=$TAG \
  | less

Lisez cette sortie sérieusement. La plupart des incidents Kubernetes sont visibles dans le manifeste rendu avant même d'atteindre un cluster.

Installez avec server-side apply, la stratégie de réconciliation par défaut de Helm 4 pour les nouvelles releases :

kubectl create namespace production --dry-run=client -o yaml | kubectl apply -f -
 
helm install nextjs-app charts/nextjs-app \
  --namespace production \
  -f charts/nextjs-app/values-production.yaml \
  --set image.tag=$TAG \
  --atomic \
  --timeout 5m

--atomic est le drapeau qui transforme un déploiement raté en non-événement : si la release ne devient pas saine dans le délai imparti, Helm effectue automatiquement un rollback au lieu de vous laisser à moitié déployé.

Suivez la montée :

kubectl -n production rollout status deployment/nextjs-app
kubectl -n production get pods -l app.kubernetes.io/name=nextjs-app

Si un Pod reste bloqué, les commandes utiles dans l'ordre sont :

kubectl -n production describe pod <pod-name>          # événements, échecs de sondes
kubectl -n production logs <pod-name> --previous       # logs du conteneur planté
kubectl -n production get events --sort-by=.lastTimestamp | tail -20

Pour une mise à jour ultérieure, Helm 4 permet de migrer explicitement une release existante vers server-side apply :

helm upgrade nextjs-app charts/nextjs-app \
  --namespace production \
  -f charts/nextjs-app/values-production.yaml \
  --set image.tag=$NEW_TAG \
  --server-side \
  --atomic

Helm 4 autorise aussi un seul fichier de valeurs contenant plusieurs documents YAML séparés par ---. C'est réellement pratique pour découper un gros values-production.yaml en sections commentées sans jongler avec six drapeaux -f dans la CI.

Étape 8 : confier les commandes à Argo CD

Tout ce qui précède exige encore qu'un humain lance helm upgrade — donc que la CI détienne les identifiants du cluster, et que le cluster puisse dériver silencieusement de Git. Argo CD inverse cela : il tourne dans le cluster, tire depuis Git et réconcilie en continu.

Installez Argo CD 3.3 :

kubectl create namespace argocd
 
kubectl apply -n argocd \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.3.11/manifests/install.yaml
 
kubectl -n argocd rollout status deployment/argocd-server

Récupérez le mot de passe admin initial et connectez-vous :

kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d; echo
 
kubectl -n argocd port-forward svc/argocd-server 8080:443
# puis ouvrez https://localhost:8080

Définissez maintenant l'application de production de façon déclarative. Committez ceci en tant que argocd/production.yaml :

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: nextjs-app-production
  namespace: argocd
  finalizers:
    # Garantit que supprimer l'Application supprime aussi ses ressources
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
 
  source:
    repoURL: https://github.com/your-org/your-repo.git
    targetRevision: main
    path: charts/nextjs-app
    helm:
      valueFiles:
        - values.yaml
        - values-production.yaml
 
  destination:
    server: https://kubernetes.default.svc
    namespace: production
 
  syncPolicy:
    automated:
      # Supprime les ressources retirées de Git
      prune: true
      # Annule les modifications kubectl manuelles pour revenir à l'état Git
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
      - ServerSideApply=true
      - PruneLast=true
    retry:
      limit: 5
      backoff:
        duration: 10s
        factor: 2
        maxDuration: 3m
 
  revisionHistoryLimit: 10

Appliquez-le une fois, et à partir de là Git devient la seule interface :

kubectl apply -f argocd/production.yaml
argocd app get nextjs-app-production

Les deux réglages qui changent la façon de travailler de votre équipe sont prune et selfHeal. Avec selfHeal: true, quiconque lance kubectl edit deployment en production voit sa modification silencieusement annulée en quelques secondes. C'est précisément le but — le cluster est une projection de Git, pas un endroit que l'on édite.

Activez selfHeal d'abord en staging et vivez avec pendant une semaine. Les équipes habituées à corriger à chaud directement sur le cluster le trouvent réellement perturbant au début, et le découvrir pendant un incident de production est le mauvais moment.

Étape 9 : boucler la boucle depuis la CI

Le pipeline de CI n'a plus que deux tâches : construire une image et écrire le nouveau tag dans Git. Il ne touche jamais au cluster.

Fichier .github/workflows/deploy.yml :

name: Build and Deploy
 
on:
  push:
    branches: [main]
 
permissions:
  contents: write
  packages: write
 
jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
 
      - id: meta
        run: echo "tag=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
 
      - uses: docker/setup-buildx-action@v3
 
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.tag }}
          build-args: |
            NEXT_PUBLIC_SITE_URL=https://app.example.com
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  promote:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      # Tout le « déploiement » est un commit. Argo CD fait le reste.
      - name: Bump image tag in the production values file
        run: |
          sed -i "s|^  tag: .*|  tag: \"${{ needs.build.outputs.tag }}\"|" \
            charts/nextjs-app/values-production.yaml
 
      - name: Commit and push
        run: |
          git config user.name  "ci-bot"
          git config user.email "ci-bot@example.com"
          git add charts/nextjs-app/values-production.yaml
          git commit -m "chore(deploy): production -> ${{ needs.build.outputs.tag }}"
          git push

Ajoutez une clé image.tag dans values-production.yaml pour que ce sed ait une cible :

image:
  tag: "placeholder"

Le rollback devient une opération Git :

# Annulez le commit de déploiement — Argo CD resynchronise l'image précédente
git revert <deploy-commit-sha> && git push
 
# Ou, en urgence, revenez en arrière directement dans Argo CD
argocd app history nextjs-app-production
argocd app rollback nextjs-app-production <history-id>

Notez qu'un rollback Argo CD laisse délibérément le cluster désynchronisé de Git. Faites-le suivre d'un git revert, sinon selfHeal ramènera aussitôt la version défectueuse.

Tester votre déploiement

Vérifiez chaque couche indépendamment plutôt que de simplement charger le site une fois.

L'image tourne en autonome :

docker run --rm -p 3000:3000 -e HOSTNAME=0.0.0.0 $IMAGE:$TAG
curl -f http://localhost:3000/api/live

Les sondes se comportent correctement en cas de panne. Arrêtez votre base de données et confirmez que la readiness échoue pendant que la liveness passe toujours — les Pods doivent quitter le load balancer sans redémarrer :

kubectl -n production get pods -w
# READY doit passer de 1/1 à 0/1, mais RESTARTS doit rester à 0

Les déploiements sont vraiment sans interruption. Lancez un générateur de charge contre l'Ingress pendant un déploiement :

kubectl run loadtest --rm -it --image=williamyeh/hey -- \
  -z 120s -c 20 https://app.example.com/api/live

Toute réponse hors 2xx pointe vers un hook preStop manquant, une sonde readiness qui renvoie 200 trop tôt, ou un maxUnavailable supérieur à zéro.

L'autoscaling réagit :

kubectl -n production get hpa nextjs-app -w

Sous charge soutenue, les répliques doivent monter en une minute environ et redescendre au fil de la fenêtre de stabilisation de cinq minutes.

L'auto-réparation fonctionne. Prouvez que la boucle GitOps est réelle :

kubectl -n production scale deployment nextjs-app --replicas=1
sleep 20
kubectl -n production get deployment nextjs-app   # revenu au nombre déclaré dans Git

Dépannage

Pods en CrashLoopBackOff avec des logs vides. Presque toujours le binding HOSTNAME. Confirmez avec kubectl exec dans un Pod actif et vérifiez que le serveur écoute sur 0.0.0.0:3000, pas sur 127.0.0.1:3000.

404 sur le CSS et le JavaScript alors que le HTML se charge. Vous n'avez pas copié .next/static dans l'étape d'exécution, ou vous l'avez copié au mauvais endroit. Il doit atterrir dans /app/.next/static.

EROFS: read-only file system dans les logs. Next.js tente d'écrire quelque part que vous n'avez pas monté. Ajoutez un emptyDir pour ce chemin, ou définissez outputFileTracingRoot pour qu'il écrive dans /app/.next/cache.

Les pages ISR se comportent différemment selon les Pods. Chaque Pod possède son propre cache emptyDir : une revalidation sur un Pod est invisible pour les autres. Configurez un gestionnaire de cache partagé adossé à Redis via cacheHandler dans next.config.ts, ou acceptez le cache par Pod et raccourcissez vos fenêtres de revalidation.

Argo CD reste en permanence OutOfSync sans diff visible. Généralement un webhook d'admission mutant (l'injecteur de sidecar d'un service mesh, par exemple) qui modifie les ressources après l'apply. Ajoutez ServerSideApply=true — ce que le manifeste ci-dessus fait déjà — ou annotez le champ concerné avec argocd.argoproj.io/compare-options: IgnoreExtraneous.

Échec d'un upgrade Helm avec un conflit de propriété de champ. C'est le server-side apply qui fonctionne comme prévu : autre chose a modifié un champ géré par Helm. Identifiez l'autre propriétaire avec kubectl get deployment nextjs-app -o yaml | grep -A20 managedFields, puis supprimez le contrôleur en conflit ou passez --force-conflicts=true si Helm doit réellement l'emporter.

Chute de trafic pendant les mises à jour de nœuds. Votre PodDisruptionBudget est absent, ou minAvailable est trop bas par rapport à replicaCount.

Prochaines étapes

Le cluster tourne et se réconcilie. Trois directions apportent le plus de valeur ensuite :

  1. Observabilité. Envoyez les traces depuis l'application elle-même — Next.js avec OpenTelemetry se marie naturellement aux métriques Prometheus au niveau cluster, et Sentry pour Next.js 16 couvre le suivi d'erreurs à travers les redémarrages de Pods.
  2. Livraison progressive. Argo Rollouts remplace le Deployment par une ressource Rollout prenant en charge les stratégies canary et blue-green, avec promotion automatique conditionnée à de vraies métriques.
  3. Passage à l'échelle multi-environnements. Le générateur ApplicationSet d'Argo CD crée une Application par environnement ou par branche de prévisualisation depuis un seul template — c'est ainsi que l'on obtient des environnements de préversion éphémères sur sa propre infrastructure.

Si tout cet appareillage vous paraît plus lourd que ce que mérite votre application — c'est probablement le cas. Kamal 2 sur un VPS offre des déploiements sans interruption avec une fraction des pièces mobiles, et Coolify vous donne une expérience de plateforme auto-hébergée. Réservez Kubernetes au cas où vous avez plusieurs services, de vrais besoins de mise à l'échelle, ou une équipe plateforme pour en assumer la charge.

Conclusion

Ce qui rend cette installation prête pour la production, ce ne sont pas les primitives Kubernetes elles-mêmes — ce sont les détails qui les entourent. Une image standalone liée à 0.0.0.0. Des sondes liveness et readiness qui répondent à des questions différentes, pour qu'une panne de base draine le trafic au lieu de redémarrer tous les Pods. Un maxUnavailable: 0 et un hook preStop qui, ensemble, rendent les déploiements réellement transparents. Un budget de disruption pour qu'une maintenance planifiée ne soit pas une panne. Des requests CPU sans limites CPU, pour garder la boucle d'événements hors du throttling CFS.

Par-dessus, Helm 4 vous donne un chart unique avec des valeurs par environnement et des mises à jour atomiques qui se rollbackent d'elles-mêmes, tandis qu'Argo CD fait de Git le seul moyen de modifier le cluster. Les déploiements deviennent des commits, les rollbacks deviennent des reverts, et « qu'est-ce qui tourne en production » cesse d'être une question à laquelle il faut répondre au jugé.