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.yamletvalues-production.yaml - Des sondes liveness, readiness et startup branchées sur un vrai endpoint de santé
- Un
HorizontalPodAutoscaleret unPodDisruptionBudgetpour 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,
kindou 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 --versionVous 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:latestTaguez 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.yamlRemplacez 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.comEt 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-secretsPour 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-stagingvalues-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 \
| lessLisez 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-appSi 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 -20Pour 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 \
--atomicHelm 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-serverRé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:8080Dé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: 10Appliquez-le une fois, et à partir de là Git devient la seule interface :
kubectl apply -f argocd/production.yaml
argocd app get nextjs-app-productionLes 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 pushAjoutez 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/liveLes 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 à 0Les 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/liveToute 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 -wSous 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 GitDé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 :
- 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.
- Livraison progressive. Argo Rollouts remplace le
Deploymentpar une ressourceRolloutprenant en charge les stratégies canary et blue-green, avec promotion automatique conditionnée à de vraies métriques. - 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é.