Pipeline GitLab CI/CD pour Next.js sur Kubernetes

Un pipeline GitLab CI/CD complet pour une app Next.js : cache pnpm, tests, image Docker multi-stage, déploiement Kubernetes avec Helm ou Argo CD.

Câbles réseau turquoise branchés sur un panneau de brassage dans une baie de serveurs

Réponse courte : un pipeline GitLab CI/CD utilisable en production pour une application Next.js tient en quatre étapes — install (dépendances mises en cache), test (lint + tests + next build), build (image Docker multi-stage poussée dans le registre GitLab), deploy (soit helm upgrade --install depuis un job CI, soit un commit sur un dépôt de manifestes que Argo CD réconcilie). Avec un cache pnpm correctement déclaré et le mode output: 'standalone', on obtient une image d'environ 150 à 200 Mo et un pipeline de 3 à 6 minutes sur un runner partagé.

La plupart des équipes que nous reprenons ont déjà « une CI ». Elle lance les tests, et le déploiement se fait avec un ssh et un docker compose pull dans un job when: manual. Ça marche jusqu'au jour où il faut déployer deux fois par jour, revenir en arrière en deux minutes, ou expliquer à un nouvel arrivant quelle version tourne réellement en production. Cet article décrit le pipeline que nous installons par défaut, fichier par fichier.

Câbles réseau turquoise branchés sur un panneau de brassage dans une baie de serveurs

Le Dockerfile avant la CI

Rien ne sert d'optimiser le pipeline si l'image pèse 1,2 Go parce qu'elle embarque node_modules en entier. Next.js fournit depuis la version 12 un mode de sortie autonome qui recopie dans .next/standalone uniquement les fichiers réellement requis par le serveur, en suivant le graphe de dépendances. Il s'active dans next.config.ts :

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'standalone',
}

export default nextConfig

Le Dockerfile qui va avec, en trois étages :

# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile

FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -S nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Trois points qui coûtent cher quand on les oublie :

  • HOSTNAME=0.0.0.0. Le serveur autonome écoute sur localhost par défaut ; dans un conteneur, la sonde de vivacité de Kubernetes n'arrive alors jamais à joindre le port et le pod boucle en CrashLoopBackOff.
  • Les variables NEXT_PUBLIC_* sont figées au build. Elles sont inlinées dans le JavaScript client par le compilateur. Une image construite pour la recette ne peut pas être « repromue » en production si l'URL d'API publique change : il faut soit une image par environnement, soit ne consommer ces valeurs qu'au runtime côté serveur.
  • L'utilisateur non root. Sans lui, toute PodSecurityPolicy ou tout securityContext avec runAsNonRoot: true refuse le pod.

Le .gitlab-ci.yml, étape par étape

Cache des dépendances

Le cache GitLab est la première source de minutes gaspillées. La clé doit dépendre du fichier de verrouillage, et le store pnpm doit vivre dans le répertoire du projet — GitLab n'archive que ce qui se trouve sous CI_PROJECT_DIR.

stages: [test, build, deploy]

default:
  image: node:22-alpine
  before_script:
    - corepack enable
    - pnpm config set store-dir .pnpm-store

variables:
  IMAGE: $CI_REGISTRY_IMAGE
  TAG: $CI_COMMIT_SHORT_SHA

.node_cache: &node_cache
  key:
    files: [pnpm-lock.yaml]
  paths:
    - .pnpm-store
    - .next/cache
  policy: pull-push

lint_test:
  stage: test
  cache: *node_cache
  script:
    - pnpm install --frozen-lockfile
    - pnpm lint
    - pnpm exec tsc --noEmit
    - pnpm test --run
  artifacts:
    when: always
    reports:
      junit: junit.xml

.next/cache dans le cache n'est pas un détail : c'est le cache de compilation de Next.js. La documentation officielle de Next.js (« Continuous Integration (CI) Build Caching ») le signale explicitement — sans lui, chaque build repart de zéro et le message « no build cache found » apparaît dans les logs. Sur un projet de taille moyenne, nous mesurons régulièrement un passage de 4 minutes à 90 secondes sur l'étape de build.

Construction de l'image avec Buildah ou Kaniko

Le docker:dind historique fonctionne encore mais exige un runner privilégié, ce qui est un problème sur un cluster partagé. Deux alternatives sans démon :

build_image:
  stage: build
  image: quay.io/buildah/stable:v1.38
  variables:
    STORAGE_DRIVER: vfs
    BUILDAH_FORMAT: docker
  script:
    - echo "$CI_REGISTRY_PASSWORD" | buildah login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - buildah build --layers -t "$IMAGE:$TAG" -t "$IMAGE:latest" .
    - buildah push "$IMAGE:$TAG"
    - buildah push "$IMAGE:latest"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

--layers active le cache de couches local ; sur un runner éphémère il n'apporte rien, mais sur un runner dédié à l'équipe il divise le temps par deux. Kaniko (gcr.io/kaniko-project/executor:debug) rend le même service avec --cache=true --cache-repo=$CI_REGISTRY_IMAGE/cache, utile justement quand les runners sont jetables puisque le cache vit dans le registre.

Côté nommage : taguer sur $CI_COMMIT_SHORT_SHA et jamais seulement sur latest. Un tag immuable est la condition pour qu'un kubectl rollout undo ait un sens, et pour qu'Argo CD détecte réellement un changement.

Le registre GitLab grossit vite : une image Next.js de 180 Mo poussée dix fois par jour, c'est plus de 50 Go par mois. Activez une politique de nettoyage (Settings → Packages and registries → Clean up image tags) qui conserve les 10 derniers tags et supprime ceux de plus de 30 jours, sinon la facture de stockage arrive avant la mise en production.

Pour que le cluster puisse tirer ces images, il faut un imagePullSecret adossé à un deploy token — le sujet est détaillé dans notre guide sur les images GitLab privées sur Kubernetes.

Déploiement : helm upgrade depuis la CI

L'approche la plus directe. Un chart Helm minimal dans deploy/chart, et un job qui pousse la nouvelle version.

.deploy_template: &deploy
  stage: deploy
  image: alpine/helm:3.16.3
  script:
    - echo "$KUBE_CONFIG" | base64 -d > kubeconfig
    - export KUBECONFIG=$PWD/kubeconfig
    - helm upgrade --install $RELEASE deploy/chart
        --namespace $NAMESPACE --create-namespace
        --set image.repository=$IMAGE
        --set image.tag=$TAG
        --values deploy/values.$ENV.yaml
        --atomic --timeout 5m

deploy_staging:
  <<: *deploy
  variables: { ENV: staging, NAMESPACE: web-staging, RELEASE: site-staging }
  environment:
    name: staging
    url: https://staging.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy_prod:
  <<: *deploy
  variables: { ENV: prod, NAMESPACE: web-prod, RELEASE: site }
  environment:
    name: production
    url: https://example.com
  when: manual
  allow_failure: false
  rules:
    - if: $CI_COMMIT_TAG

Les deux options qui changent tout : --atomic, qui annule automatiquement la release si les pods ne deviennent pas prêts, et --timeout, sans lequel un job peut rester bloqué une heure. Le KUBE_CONFIG est stocké en variable masquée et protégée dans GitLab, et le ServiceAccount associé ne doit pouvoir écrire que dans ses deux namespaces — un ClusterRoleBinding sur cluster-admin pour un job de déploiement est la faille la plus fréquente que nous trouvons en audit.

Le déploiement doit aussi être sans coupure. Côté chart :

strategy:
  type: RollingUpdate
  rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
readinessProbe:
  httpGet: { path: /api/health, port: 3000 }
  periodSeconds: 5
livenessProbe:
  httpGet: { path: /api/health, port: 3000 }
  initialDelaySeconds: 20
resources:
  requests: { cpu: 100m, memory: 256Mi }
  limits: { memory: 512Mi }

Une route app/api/health/route.ts qui renvoie new Response('ok') suffit — elle doit juste ne dépendre d'aucune base de données, sinon une lenteur PostgreSQL fait redémarrer tous les pods en cascade. Sur le dimensionnement mémoire d'un serveur Next.js, 256 Mi en requête et 512 Mi en limite couvrent un site vitrine ou un back-office modeste ; au-delà de l'ISR intensif, il faut mesurer avant de fixer la limite, car un OOMKilled sur un rendu serveur se traduit par des 502 côté visiteur.

Déploiement : Argo CD en GitOps

À partir de deux environnements et de plus d'un déploiement par jour, le helm upgrade depuis la CI montre ses limites : la CI détient des droits d'écriture sur le cluster, et l'état réel du cluster n'est écrit nulle part. Le modèle GitOps inverse le flux — la CI ne touche plus au cluster, elle écrit un commit ; un agent dans le cluster (Argo CD, Flux) tire et réconcilie.

Concrètement, le job de déploiement devient un job de mise à jour de manifeste :

bump_manifest:
  stage: deploy
  image: alpine/git:2.47.1
  script:
    - git clone https://bot:$DEPLOY_TOKEN@gitlab.com/acme/deploy-manifests.git m
    - cd m
    - sed -i "s|tag: .*|tag: $TAG|" apps/site/values-prod.yaml
    - git commit -am "site: deploy $TAG" && git push
  rules:
    - if: $CI_COMMIT_TAG

Et l'application Argo CD, côté cluster :

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: site
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://gitlab.com/acme/deploy-manifests.git
    targetRevision: main
    path: apps/site
    helm:
      valueFiles: [values-prod.yaml]
  destination:
    server: https://kubernetes.default.svc
    namespace: web-prod
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [CreateNamespace=true]

Ce qu'on gagne : selfHeal ramène le cluster à l'état du dépôt si quelqu'un a fait un kubectl edit à la main, l'historique Git est l'historique des déploiements, et un retour arrière est un git revert. Ce qu'on paie : un dépôt de plus, un composant de plus à héberger (Argo CD consomme autour de 1 vCPU et 1 Go de RAM pour une installation modeste, soit environ 15 à 25 € par mois de nœud chez Scaleway ou OVHcloud), et une latence de synchronisation par défaut de 3 minutes si l'on ne configure pas le webhook GitLab.

Notre règle pratique :

ContexteRecommandation
1 à 2 applications, 1 cluster, une équipehelm upgrade depuis la CI
3 applications et plus, ou 2 clustersArgo CD
Plusieurs équipes déploient sur le même clusterArgo CD, avec AppProject par équipe
Exigence d'audit « qui a déployé quoi »Argo CD

Les erreurs qui reviennent le plus

Lancer next build deux fois. Une fois dans le job de test, une fois dans le Dockerfile. C'est deux à quatre minutes perdues à chaque pipeline. Soit le job de test se contente de tsc --noEmit et des tests unitaires, soit le build produit un artefact réutilisé — mais mélanger les deux approches coûte sans rien apporter.

Oublier --frozen-lockfile. Sans cette option, pnpm ou npm peut résoudre une version mineure différente entre la machine du développeur et la CI. C'est la cause classique du « ça marche chez moi ».

Un seul réplica avec maxUnavailable: 1. Le site est indisponible pendant chaque déploiement. Deux réplicas et maxUnavailable: 0, plus un PodDisruptionBudget avec minAvailable: 1, suffisent à rendre les mises à jour et les évictions de nœud invisibles.

Les secrets dans les variables CI plutôt que dans le cluster. Une variable GitLab injectée dans --set apparaît dans l'historique Helm et donc dans un Secret du namespace, en clair pour quiconque peut lire ce namespace. Les secrets applicatifs appartiennent au cluster — External Secrets Operator ou Sealed Secrets — pas au pipeline.

Ne pas protéger les branches et les tags. Les variables « protégées » n'ont de sens que si la branche par défaut et le motif de tags v* sont déclarés protégés dans GitLab, avec un push interdit en direct.

Sur l'entrée du trafic vers ces pods, si votre cluster est encore sur ingress-nginx, le chantier de bascule est à planifier : nous l'avons détaillé dans notre article sur la Gateway API. Et pour la base de données qui accompagne l'application, le comparatif CloudNativePG contre base managée donne les ordres de grandeur.

Questions fréquentes

Faut-il un runner GitLab dédié ou les runners partagés suffisent-ils ?

Les runners partagés de GitLab.com conviennent jusqu'à une dizaine de pipelines par jour ; l'offre gratuite inclut un quota mensuel de minutes de calcul, consommé très vite par des builds Docker. Au-delà, un runner auto-hébergé sur une petite instance (4 vCPU, 8 Go) à 30-50 € par mois se rentabilise immédiatement, et surtout il conserve le cache de couches entre les builds. L'exécuteur Kubernetes est pratique si vous avez déjà un cluster, mais pensez à monter un volume persistant pour le cache, sinon vous perdez le gain.

Peut-on déployer des branches de fonctionnalité dans des environnements éphémères ?

Oui, c'est l'intérêt des environment: name: review/$CI_COMMIT_REF_SLUG avec on_stop. Chaque merge request obtient son namespace et son URL https://$CI_COMMIT_REF_SLUG.review.example.com, détruits à la fermeture. Le coût réel, c'est la base de données : prévoyez un schéma par environnement de revue plutôt qu'une instance par branche.

Comment gérer les migrations de base de données dans le pipeline ?

Avec un Job Kubernetes lancé par un hook Helm pre-upgrade, pas depuis le job CI. Le hook tourne dans le cluster, avec les mêmes secrets et le même réseau que l'application, et --atomic annule la release si la migration échoue. La règle reste la compatibilité ascendante : une migration doit pouvoir cohabiter avec l'ancienne version du code pendant le temps du déploiement progressif.

Combien de temps doit durer un pipeline acceptable ?

Notre seuil est de 10 minutes du git push à la recette déployée. Au-delà, les développeurs cessent de pousser souvent et les lots grossissent. Le levier numéro un est le cache (.pnpm-store et .next/cache), le numéro deux la parallélisation du lint et des tests, le numéro trois le cache de couches Docker.

En résumé

  • Activer output: 'standalone' dans Next.js et construire une image multi-stage non root avec HOSTNAME=0.0.0.0 : environ 150 à 200 Mo au lieu de plus de 1 Go.
  • Mettre en cache .pnpm-store et .next/cache avec une clé basée sur pnpm-lock.yaml ; c'est le gain de temps le plus rentable du pipeline.
  • Construire avec Buildah ou Kaniko pour éviter un runner privilégié, taguer sur le SHA de commit et activer une politique de nettoyage du registre.
  • Pour une ou deux applications, helm upgrade --install --atomic --timeout 5m depuis la CI, avec un ServiceAccount restreint aux namespaces concernés.
  • Au-delà, passer en GitOps avec Argo CD : la CI écrit un commit, le cluster se réconcilie, le retour arrière est un git revert.
  • Déploiement sans coupure : 2 réplicas, maxUnavailable: 0, sondes sur une route /api/health qui ne touche pas la base.
  • Les secrets applicatifs vivent dans le cluster (External Secrets, Sealed Secrets), jamais dans les --set de Helm.

Besoin d'un regard extérieur sur votre chaîne de livraison ? Nous intervenons sur ce genre de chantier dans le cadre de nos prestations d'infrastructure et d'exploitation et de développement applicatif ; vous pouvez nous écrire pour en parler.