Aller au contenu
·34 min de lecture

GitHub Actions CI/CD : des déploiements sans downtime qui marchent vraiment

Ma configuration GitHub Actions complète : jobs de test en parallèle, cache Docker, déploiement SSH sur VPS, zéro-downtime avec PM2, gestion des secrets et les patterns affinés sur deux ans.

Partager:X / TwitterLinkedIn

Chaque projet sur lequel j'ai travaillé finit par atteindre le même point d'inflexion : le processus de déploiement devient trop pénible pour être fait manuellement. On oublie de lancer les tests. On build en local mais on oublie de bumper la version. On se connecte en SSH à la production et on réalise que la dernière personne à avoir déployé a laissé un fichier .env périmé.

GitHub Actions a résolu ça pour moi il y a deux ans. Pas parfaitement dès le premier jour — le premier workflow que j'ai écrit était un cauchemar YAML de 200 lignes qui timeout la moitié du temps et ne cachait rien. Mais itération après itération, je suis arrivé à quelque chose qui déploie ce site de manière fiable, avec zéro downtime, en moins de quatre minutes.

Voici ce workflow, expliqué section par section. Pas la version de la doc. La version qui survit au contact avec la production.

Comprendre les briques de base#

Avant d'entrer dans le pipeline complet, il faut avoir un modèle mental clair du fonctionnement de GitHub Actions. Si tu as utilisé Jenkins ou CircleCI, oublie la plupart de ce que tu sais. Les concepts se recoupent vaguement, mais le modèle d'exécution est suffisamment différent pour te piéger.

Déclencheurs : quand ton workflow s'exécute#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Chaque lundi à 6h UTC
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

Quatre déclencheurs, chacun servant un but différent :

  • push sur main est ton déclencheur de déploiement en production. Code mergé ? On shippe.
  • pull_request lance tes vérifications CI sur chaque PR. C'est là que vivent le lint, les vérifications de types et les tests.
  • schedule est le cron de ton repo. Je l'utilise pour les scans d'audit de dépendances hebdomadaires et le nettoyage des caches périmés.
  • workflow_dispatch te donne un bouton manuel « Deploy » dans l'interface GitHub avec des paramètres d'entrée. Inestimable quand tu dois déployer le staging sans changement de code — peut-être que tu as mis à jour une variable d'environnement ou que tu as besoin de re-pull une image Docker de base.

Un point qui piège les gens : pull_request s'exécute contre le commit de merge, pas le HEAD de la branche PR. Ça signifie que ta CI teste ce à quoi le code ressemblera après le merge. C'est en fait ce qu'on veut, mais ça surprend les gens quand une branche verte devient rouge après un rebase.

Jobs, steps et runners#

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

Les jobs s'exécutent en parallèle par défaut. Chaque job obtient une VM fraîche (le « runner »). ubuntu-latest te donne une machine raisonnablement puissante — 4 vCPUs, 16 Go de RAM en 2026. C'est gratuit pour les repos publics, 2000 minutes/mois pour les privés.

Les steps s'exécutent séquentiellement au sein d'un job. Chaque step uses: tire une action réutilisable depuis le marketplace. Chaque step run: exécute une commande shell.

Le flag --frozen-lockfile est crucial. Sans lui, pnpm install pourrait mettre à jour ton lockfile pendant la CI, ce qui signifie que tu ne testes pas les mêmes dépendances que celles committées par le développeur. J'ai vu ça causer des échecs de tests fantômes qui disparaissent en local parce que le lockfile sur la machine du développeur est déjà correct.

Variables d'environnement vs secrets#

yaml
env:
  NODE_ENV: production
  NEXT_TELEMETRY_DISABLED: 1
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          echo "$SSH_PRIVATE_KEY" > key.pem
          chmod 600 key.pem
          ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"

Les variables d'environnement définies avec env: au niveau du workflow sont en texte clair, visibles dans les logs. Utilise-les pour la configuration non sensible : NODE_ENV, flags de télémétrie, feature toggles.

Les secrets (${{ secrets.X }}) sont chiffrés au repos, masqués dans les logs, et disponibles uniquement pour les workflows du même repo. On les définit dans Settings > Secrets and variables > Actions.

La ligne environment: production est significative. Les Environments de GitHub te permettent de limiter les secrets à des cibles de déploiement spécifiques. Ta clé SSH de staging et ta clé SSH de production peuvent toutes deux s'appeler SSH_PRIVATE_KEY mais contenir des valeurs différentes selon l'environnement ciblé par le job. Ça déverrouille aussi les reviewers obligatoires — tu peux conditionner les déploiements en production à une approbation manuelle.

Le pipeline CI complet#

Voici comment je structure la moitié CI du pipeline. L'objectif : détecter chaque catégorie d'erreur dans le temps le plus court possible.

yaml
name: CI
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  build:
    name: Build
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .next/
          retention-days: 1

Pourquoi cette structure#

Lint, typecheck et test s'exécutent en parallèle. Ils n'ont pas de dépendances entre eux. Une erreur de type ne bloque pas le lint, et un test échoué n'a pas besoin d'attendre le vérificateur de types. Sur une exécution typique, les trois finissent en 30-60 secondes en s'exécutant simultanément.

Build attend les trois. La ligne needs: [lint, typecheck, test] signifie que le job de build ne démarre que si lint, typecheck ET test passent tous. Ça ne sert à rien de builder un projet qui a des erreurs de lint ou de types.

concurrency avec cancel-in-progress: true fait gagner un temps énorme. Si tu pushes deux commits rapidement, le premier run CI est annulé. Sans ça, tu auras des runs périmés qui consomment ton budget de minutes et encombrent l'interface des checks.

L'upload de couverture avec if: always() signifie que tu obtiens le rapport de couverture même quand les tests échouent. C'est utile pour le debugging — tu peux voir quels tests ont échoué et ce qu'ils couvraient.

Fail-fast vs. laisser tout s'exécuter#

Par défaut, si un job dans une matrice échoue, GitHub annule les autres. Pour la CI, je veux effectivement ce comportement — si le lint échoue, je me fiche des résultats de test. Corrige le lint d'abord.

Mais pour les matrices de test (disons, tester sur Node 20 et Node 22), tu pourrais vouloir voir tous les échecs d'un coup :

yaml
test:
  strategy:
    fail-fast: false
    matrix:
      node-version: [20, 22]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: "pnpm"
    - run: pnpm install --frozen-lockfile
    - run: pnpm test

fail-fast: false laisse les deux branches de la matrice se terminer. Si Node 22 échoue mais Node 20 passe, tu vois cette information immédiatement au lieu de devoir relancer.

Le cache pour la vitesse#

L'amélioration la plus significative que tu puisses apporter à la vitesse de CI, c'est le caching. Un pnpm install à froid sur un projet moyen prend 30-45 secondes. Avec un cache chaud, ça prend 3-5 secondes. Multiplie ça par quatre jobs parallèles et tu économises deux minutes à chaque exécution.

Cache du store pnpm#

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "pnpm"

Ce one-liner cache le store pnpm (~/.local/share/pnpm/store). En cas de cache hit, pnpm install --frozen-lockfile fait juste des hard-links depuis le store au lieu de télécharger. Ça seul réduit le temps d'installation de 80% sur les exécutions répétées.

Si tu as besoin de plus de contrôle — par exemple, tu veux cacher en fonction de l'OS aussi — utilise actions/cache directement :

yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.local/share/pnpm/store
      node_modules
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

Le fallback restore-keys est important. Si pnpm-lock.yaml change (nouvelle dépendance), la clé exacte ne matchera pas, mais le match par préfixe restaurera quand même la plupart des packages cachés. Seule la différence est téléchargée.

Cache de build Next.js#

Next.js a son propre cache de build dans .next/cache. Le cacher entre les exécutions permet des builds incrémentaux — seules les pages et composants modifiés sont recompilés.

yaml
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

Cette stratégie de clé à trois niveaux signifie :

  1. Match exact : mêmes dépendances ET mêmes fichiers sources. Cache hit complet, build quasi-instantané.
  2. Match partiel (dépendances) : mêmes dépendances mais sources modifiées. Le build ne recompile que les fichiers modifiés.
  3. Match partiel (OS uniquement) : dépendances modifiées. Le build réutilise ce qu'il peut.

Chiffres réels de mon projet : build à froid ~55 secondes, build caché ~15 secondes. C'est une réduction de 73%.

Cache des layers Docker#

Les builds Docker sont là où le caching devient vraiment impactant. Un build Docker Next.js complet — installer les dépendances OS, copier les sources, lancer pnpm install, lancer next build — prend 3-4 minutes à froid. Avec le cache de layers, c'est 30-60 secondes.

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha utilise le backend de cache intégré de GitHub Actions. mode=max cache toutes les layers, pas seulement les finales. C'est crucial pour les builds multi-stage où les layers intermédiaires (comme pnpm install) sont les plus coûteuses à reconstruire.

Cache distant Turborepo#

Si tu es dans un monorepo avec Turborepo, le cache distant est transformateur. Le premier build uploade les sorties de tâches vers le cache. Les builds suivants téléchargent au lieu de recalculer.

yaml
- run: pnpm turbo build --remote-only
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

J'ai vu des temps de CI monorepo passer de 8 minutes à 90 secondes avec le cache distant Turbo. Le hic : ça nécessite un compte Vercel ou un serveur Turbo auto-hébergé. Pour les repos mono-app, c'est surdimensionné.

Build et push Docker#

Si tu déploies sur un VPS (ou n'importe quel serveur), Docker te donne des builds reproductibles. La même image qui tourne en CI est la même qui tourne en production. Plus de « ça marche sur ma machine » parce que la machine est l'image.

Dockerfile multi-stage#

Avant d'arriver au workflow, voici le Dockerfile que j'utilise pour Next.js :

dockerfile
# Stage 1: Dépendances
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
 
# Stage 2: Build
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
 
# Stage 3: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 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
ENV PORT=3000
CMD ["node", "server.js"]

Trois stages, séparation claire. L'image finale fait ~150 Mo au lieu des ~1.2 Go qu'on obtiendrait en copiant tout. Seuls les artefacts de production arrivent au stage runner.

Le workflow de build-and-push#

yaml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Décortiquons les décisions importantes ici.

GitHub Container Registry (ghcr.io)#

J'utilise ghcr.io plutôt que Docker Hub pour trois raisons :

  1. L'authentification est gratuite. GITHUB_TOKEN est automatiquement disponible dans chaque workflow — pas besoin de stocker des identifiants Docker Hub.
  2. Proximité. Les images sont tirées depuis la même infrastructure que celle où ta CI s'exécute. Les pulls pendant la CI sont rapides.
  3. Visibilité. Les images sont liées à ton repo dans l'interface GitHub. Tu les vois dans l'onglet Packages.

Builds multi-plateformes#

yaml
platforms: linux/amd64,linux/arm64

Cette ligne ajoute peut-être 90 secondes à ton build, mais ça vaut le coup. Les images ARM64 tournent nativement sur :

  • Les Mac Apple Silicon (M1/M2/M3/M4) pendant le développement local avec Docker Desktop
  • Les instances AWS Graviton (20-40% moins chères que les équivalents x86)
  • Le tier gratuit ARM d'Oracle Cloud

Sans ça, tes développeurs sur Mac série M font tourner des images x86 à travers l'émulation Rosetta. Ça marche, mais c'est nettement plus lent et ça fait parfois apparaître des bugs bizarres spécifiques à l'architecture.

QEMU fournit la couche de cross-compilation. Buildx orchestre le build multi-architecture et pousse une liste de manifestes pour que Docker tire automatiquement la bonne architecture.

Stratégie de tagging#

yaml
tags: |
  type=sha,prefix=
  type=ref,event=branch
  type=raw,value=latest,enable={{is_default_branch}}

Chaque image reçoit trois tags :

  • abc1234 (SHA du commit) : immuable. Tu peux toujours déployer un commit exact.
  • main (nom de branche) : mutable. Pointe vers le dernier build de cette branche.
  • latest : mutable. Défini uniquement sur la branche par défaut. C'est ce que ton serveur tire.

Ne déploie jamais latest en production sans aussi enregistrer le SHA quelque part. Quand quelque chose casse, tu as besoin de savoir quel latest. Je stocke le SHA déployé dans un fichier sur le serveur que l'endpoint de health lit.

Déploiement SSH sur VPS#

C'est là que tout se rejoint. La CI passe, l'image Docker est buildée et poussée, maintenant on doit dire au serveur de tirer la nouvelle image et de redémarrer.

L'action SSH#

yaml
deploy:
  name: Deploy to Production
  needs: [build-and-push]
  runs-on: ubuntu-latest
  environment: production
 
  steps:
    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.DEPLOY_HOST }}
        username: ${{ secrets.DEPLOY_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT }}
        script_stop: true
        script: |
          set -euo pipefail
 
          APP_DIR="/var/www/akousa.net"
          IMAGE="ghcr.io/${{ github.repository }}:latest"
          DEPLOY_SHA="${{ github.sha }}"
 
          echo "=== Deploying $DEPLOY_SHA ==="
 
          # Tirer la dernière image
          docker pull "$IMAGE"
 
          # Arrêter et supprimer l'ancien conteneur
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # Démarrer le nouveau conteneur
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # Attendre le health check
          echo "Waiting for health check..."
          for i in $(seq 1 30); do
            if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
              echo "Health check passed on attempt $i"
              break
            fi
            if [ "$i" -eq 30 ]; then
              echo "Health check failed after 30 attempts"
              exit 1
            fi
            sleep 2
          done
 
          # Enregistrer le SHA déployé
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # Nettoyer les anciennes images
          docker image prune -af --filter "until=168h"
 
          echo "=== Deploy complete ==="

L'alternative du script de déploiement#

Pour tout ce qui va au-delà d'un simple pull-and-restart, je déplace la logique dans un script sur le serveur plutôt que de l'inliner dans le workflow :

bash
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
 
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
 
log "Starting deployment..."
 
# Connexion au GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# Pull avec retry
for attempt in 1 2 3; do
  if docker pull "$IMAGE"; then
    log "Image pulled successfully on attempt $attempt"
    break
  fi
  if [ "$attempt" -eq 3 ]; then
    log "ERROR: Failed to pull image after 3 attempts"
    exit 1
  fi
  log "Pull attempt $attempt failed, retrying in 5s..."
  sleep 5
done
 
# Fonction de health check
health_check() {
  local port=$1
  local max_attempts=30
  for i in $(seq 1 $max_attempts); do
    if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}
 
# Démarrer le nouveau conteneur sur un port alternatif
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# Vérifier que le nouveau conteneur est sain
if ! health_check 3001; then
  log "ERROR: New container failed health check. Rolling back."
  docker stop akousa-app-new || true
  docker rm akousa-app-new || true
  exit 1
fi
 
log "New container healthy. Switching traffic..."
 
# Basculer l'upstream Nginx
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Arrêter l'ancien conteneur
docker stop akousa-app || true
docker rm akousa-app || true
 
# Renommer le nouveau conteneur
docker rename akousa-app-new akousa-app
 
log "Deployment complete."

Le workflow se réduit alors à une seule commande SSH :

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

C'est mieux parce que : (1) la logique de déploiement est versionnée sur le serveur, (2) tu peux l'exécuter manuellement en SSH pour le debugging, et (3) tu n'as pas à échapper du YAML dans du YAML dans du bash.

Stratégies zéro-downtime#

« Zéro downtime » ressemble à du marketing, mais ça a un sens précis : aucune requête ne reçoit un « connection refused » ou un 502 pendant le déploiement. Voici trois vraies approches, de la plus simple à la plus robuste.

Stratégie 1 : PM2 Cluster Mode Reload#

Si tu fais tourner Node.js directement (pas dans Docker), le mode cluster de PM2 te donne le chemin zéro-downtime le plus simple.

bash
# ecosystem.config.js a déjà :
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (pas restart) fait un redémarrage progressif. Il lance de nouveaux workers, attend qu'ils soient prêts, puis tue les anciens workers un par un. À aucun moment zéro worker ne sert du trafic.

Le flag --update-env recharge les variables d'environnement depuis la config ecosystem. Sans lui, ton ancien env persiste même après un déploiement qui a modifié .env.

Dans ton workflow :

yaml
- name: Deploy and reload PM2
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

C'est ce que j'utilise pour ce site. C'est simple, fiable, et le downtime est littéralement zéro — je l'ai testé avec un générateur de charge faisant 100 req/s pendant les déploiements. Pas un seul 5xx.

Stratégie 2 : Blue/Green avec Nginx Upstream#

Pour les déploiements Docker, le blue/green te donne une séparation nette entre l'ancienne et la nouvelle version.

Le concept : faire tourner l'ancien conteneur (« blue ») sur le port 3000 et le nouveau conteneur (« green ») sur le port 3001. Nginx pointe vers blue. Tu démarres green, tu vérifies qu'il est sain, tu bascules Nginx vers green, puis tu arrêtes blue.

Configuration upstream Nginx :

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /etc/nginx/sites-available/akousa.net
server {
    listen 443 ssl http2;
    server_name akousa.net;
 
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

Le script de bascule :

bash
#!/bin/bash
set -euo pipefail
 
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
 
if [ "$CURRENT_PORT" = "3000" ]; then
  NEW_PORT=3001
  OLD_PORT=3000
else
  NEW_PORT=3000
  OLD_PORT=3001
fi
 
echo "Current: $OLD_PORT -> New: $NEW_PORT"
 
# Démarrer le nouveau conteneur sur le port alternatif
docker run -d \
  --name "akousa-app-$NEW_PORT" \
  --env-file /var/www/akousa.net/.env.production \
  -p "$NEW_PORT:3000" \
  "ghcr.io/akousa/akousa-net:latest"
 
# Attendre la santé
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "New container healthy on port $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# Basculer Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Arrêter l'ancien conteneur
sleep 5  # Laisser les requêtes en cours se terminer
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Switched from :$OLD_PORT to :$NEW_PORT"

Le sleep de 5 secondes après le reload Nginx n'est pas de la paresse — c'est un temps de grâce. Le reload de Nginx est gracieux (les connexions existantes sont maintenues), mais certaines connexions long-polling ou réponses en streaming ont besoin de temps pour se terminer.

Stratégie 3 : Docker Compose avec Health Checks#

Pour une approche plus structurée, Docker Compose peut gérer le swap blue/green :

yaml
# docker-compose.yml
services:
  app:
    image: ghcr.io/akousa/akousa-net:latest
    restart: unless-stopped
    env_file: .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    ports:
      - "3000:3000"

La ligne order: start-first est la clé. Elle signifie « démarrer le nouveau conteneur avant d'arrêter l'ancien ». Combinée avec parallelism: 1, tu obtiens une mise à jour progressive — un conteneur à la fois, en maintenant toujours la capacité.

Déploie avec :

bash
docker compose pull
docker compose up -d --remove-orphans

Docker Compose surveille le healthcheck et ne route pas le trafic vers le nouveau conteneur tant qu'il ne passe pas. Si le healthcheck échoue, failure_action: rollback revient automatiquement à la version précédente. C'est ce qui se rapproche le plus des rolling deployments à la Kubernetes sur un seul VPS.

Gestion des secrets#

La gestion des secrets est une de ces choses qu'on réussit « presque » facilement et qu'on rate catastrophiquement dans les cas limites restants.

GitHub Secrets : les bases#

yaml
# Définis via l'interface GitHub : Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # La valeur est masquée dans les logs
      echo "Connecting to database..."
      # Ça afficherait "Connecting to ***" dans les logs
      echo "Connecting to $DB_URL"

GitHub masque automatiquement les valeurs de secrets dans les logs. Si ton secret est p@ssw0rd123 et qu'un step affiche cette chaîne, les logs montrent ***. Ça marche bien, avec un bémol : si ton secret est court (comme un PIN à 4 chiffres), GitHub pourrait ne pas le masquer parce qu'il pourrait matcher des chaînes innocentes. Garde tes secrets raisonnablement complexes.

Secrets à portée d'environnement#

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = staging.akousa.net
 
  deploy-production:
    environment: production
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = akousa.net

Même nom de secret, valeurs différentes par environnement. Le champ environment sur le job détermine quel jeu de secrets est injecté.

Les environnements de production devraient avoir les reviewers obligatoires activés. Ça signifie qu'un push sur main déclenche le workflow, la CI s'exécute automatiquement, mais le job de déploiement se met en pause et attend que quelqu'un clique « Approve » dans l'interface GitHub. Pour un projet solo, ça peut sembler superflue. Pour tout ce qui a des utilisateurs, c'est un sauveur la première fois que tu merges accidentellement quelque chose de cassé.

OIDC : plus de credentials statiques#

Les credentials statiques (clés d'accès AWS, fichiers JSON de compte de service GCP) stockées dans les GitHub Secrets sont un risque. Elles n'expirent pas, elles ne peuvent pas être limitées à une exécution de workflow spécifique, et si elles fuitent, il faut les faire tourner manuellement.

OIDC (OpenID Connect) résout ça. GitHub Actions agit comme un fournisseur d'identité, et ton fournisseur cloud lui fait confiance pour émettre des credentials éphémères à la volée :

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Requis pour OIDC
      contents: read
 
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-central-1
 
      - name: Push to ECR
        run: |
          aws ecr get-login-password --region eu-central-1 | \
            docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com

Pas de clé d'accès. Pas de clé secrète. L'action configure-aws-credentials demande un token temporaire à AWS STS en utilisant le token OIDC de GitHub. Le token est limité au repo, à la branche et à l'environnement spécifiques. Il expire après l'exécution du workflow.

Mettre ça en place côté AWS nécessite un fournisseur d'identité OIDC IAM et une politique de confiance de rôle :

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
        }
      }
    }
  ]
}

La condition sub est cruciale. Sans elle, n'importe quel repo qui obtiendrait les détails de ton fournisseur OIDC pourrait assumer le rôle. Avec elle, seule la branche main de ton repo spécifique le peut.

GCP a une configuration équivalente avec Workload Identity Federation. Azure a les federated credentials. Si ton cloud supporte OIDC, utilise-le. Il n'y a aucune raison de stocker des credentials cloud statiques en 2026.

Clés SSH de déploiement#

Pour les déploiements VPS via SSH, génère une paire de clés dédiée :

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

Ajoute la clé publique au ~/.ssh/authorized_keys du serveur avec des restrictions :

restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy

Le préfixe restrict désactive le port forwarding, l'agent forwarding, l'allocation PTY et le X11 forwarding. Le préfixe command= signifie que cette clé peut uniquement exécuter le script de déploiement. Même si la clé privée est compromise, l'attaquant peut exécuter ton script de déploiement et rien d'autre.

Ajoute la clé privée aux GitHub Secrets comme SSH_PRIVATE_KEY. C'est la seule credential statique que j'accepte — les clés SSH avec commandes forcées ont un rayon d'impact très limité.

Workflows de PR : déploiements preview#

Chaque PR mérite un environnement preview. Ça détecte les bugs visuels que les tests unitaires ratent, permet aux designers de reviewer sans checkout de code, et rend la vie de la QA dramatiquement plus simple.

Déployer un preview à l'ouverture de PR#

yaml
name: Preview Deploy
 
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  preview:
    runs-on: ubuntu-latest
    environment:
      name: preview-${{ github.event.number }}
      url: ${{ steps.deploy.outputs.url }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Build preview image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Deploy preview
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            PORT=$((4000 + PR_NUM))
            IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
 
            docker pull "$IMAGE"
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
 
            docker run -d \
              --name "preview-${PR_NUM}" \
              --restart unless-stopped \
              -e NODE_ENV=preview \
              -p "${PORT}:3000" \
              "$IMAGE"
 
            echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
 
      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
            const body = `### Preview Deployment
 
            | Status | URL |
            |--------|-----|
            | :white_check_mark: Deployed | [${url}](${url}) |
 
            _Last updated: ${new Date().toISOString()}_
            _Commit: \`${{ github.sha }}\`_`;
 
            // Trouver le commentaire existant
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
 
            const botComment = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Deployment')
            );
 
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

Le calcul de port (4000 + PR_NUM) est un hack pragmatique. La PR #42 obtient le port 4042. Tant que tu n'as pas plus de quelques centaines de PRs ouvertes, il n'y a pas de collision. Une config Nginx wildcard route pr-*.preview.akousa.net vers le bon port.

Nettoyage à la fermeture de PR#

Les environnements preview qui ne sont pas nettoyés consomment du disque et de la mémoire. Ajoute un job de nettoyage :

yaml
name: Cleanup Preview
 
on:
  pull_request:
    types: [closed]
 
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview container
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
            docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
            echo "Preview for PR #${PR_NUM} cleaned up."
 
      - name: Deactivate environment
        uses: actions/github-script@v7
        with:
          script: |
            const deployments = await github.rest.repos.listDeployments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              environment: `preview-${{ github.event.number }}`,
            });
 
            for (const deployment of deployments.data) {
              await github.rest.repos.createDeploymentStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                deployment_id: deployment.id,
                state: 'inactive',
              });
            }

Checks de statut obligatoires#

Dans les paramètres de ton dépôt (Settings > Branches > Branch protection rules), exige ces vérifications avant le merge :

  • lint — Pas d'erreurs de lint
  • typecheck — Pas d'erreurs de types
  • test — Tous les tests passent
  • build — Le projet builde avec succès

Sans ça, quelqu'un va merger une PR avec des checks en échec. Pas par malveillance — ils verront « 2 sur 4 checks passés » et supposeront que les deux autres sont encore en cours. Verrouille ça.

Active aussi « Require branches to be up to date before merging. » Ça force un re-run de la CI après un rebase sur le dernier main. Ça détecte le cas où deux PRs passent individuellement la CI mais créent un conflit quand elles sont combinées.

Notifications#

Un déploiement dont personne n'est au courant est un déploiement auquel personne ne fait confiance. Les notifications ferment la boucle de feedback.

Webhook Slack#

yaml
- name: Notify Slack
  if: always()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "blocks": [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
            }
          },
          {
            "type": "section",
            "fields": [
              {
                "type": "mrkdwn",
                "text": "*Repository:*\n${{ github.repository }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Branch:*\n${{ github.ref_name }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
              },
              {
                "type": "mrkdwn",
                "text": "*Triggered by:*\n${{ github.actor }}"
              }
            ]
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {
                  "type": "plain_text",
                  "text": "View Run"
                },
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            ]
          }
        ]
      }

Le if: always() est critique. Sans lui, le step de notification est ignoré quand le déploiement échoue — ce qui est exactement le moment où tu en as le plus besoin.

API Deployments de GitHub#

Pour un suivi de déploiement plus riche, utilise l'API Deployments de GitHub. Ça te donne un historique de déploiement dans l'interface du repo et permet les badges de statut :

yaml
- name: Create GitHub Deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: context.sha,
        environment: 'production',
        auto_merge: false,
        required_contexts: [],
        description: `Deploying ${context.sha.substring(0, 7)} to production`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... étapes de déploiement réelles ...
 
- name: Update deployment status
  if: always()
  uses: actions/github-script@v7
  with:
    script: |
      const deploymentId = ${{ steps.deployment.outputs.result }};
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: deploymentId,
        state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
        environment_url: 'https://akousa.net',
        log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
        description: '${{ job.status }}' === 'success'
          ? 'Deployment succeeded'
          : 'Deployment failed',
      });

Maintenant ton onglet Environments dans GitHub montre un historique de déploiement complet : qui a déployé quoi, quand, et si ça a réussi.

Email uniquement en cas d'échec#

Pour les déploiements critiques, je déclenche aussi un email en cas d'échec. Pas via l'email intégré de GitHub Actions (trop bruyant), mais via un webhook ciblé :

yaml
- name: Alert on failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "DEPLOY FAILED: ${{ github.repository }}",
        "body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'

C'est ma dernière ligne de défense. Slack c'est super mais c'est aussi bruyant — les gens mettent les canaux en sourdine. Un email « DEPLOY FAILED » avec un lien vers le run attire l'attention.

Le fichier workflow complet#

Voici tout câblé ensemble en un seul workflow prêt pour la production. C'est très proche de ce qui déploie réellement ce site.

yaml
name: CI/CD Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      skip_tests:
        description: "Skip tests (emergency deploy)"
        required: false
        type: boolean
        default: false
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
env:
  NODE_VERSION: "22"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  # ============================================================
  # CI : Lint, vérification de types et test en parallèle
  # ============================================================
 
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run ESLint
        run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run TypeScript compiler
        run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    if: ${{ !inputs.skip_tests }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run tests with coverage
        run: pnpm test -- --coverage
 
      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  # ============================================================
  # Build : uniquement après que la CI passe
  # ============================================================
 
  build:
    name: Build Application
    needs: [lint, typecheck, test]
    if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            nextjs-${{ runner.os }}-
 
      - name: Build Next.js application
        run: pnpm build
 
  # ============================================================
  # Docker : build et push de l'image (branche main uniquement)
  # ============================================================
 
  docker:
    name: Build Docker Image
    needs: [build]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Set up QEMU for multi-platform builds
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  # ============================================================
  # Deploy : SSH sur le VPS et mise à jour
  # ============================================================
 
  deploy:
    name: Deploy to Production
    needs: [docker]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://akousa.net
 
    steps:
      - name: Create GitHub Deployment
        id: deployment
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: 'production',
              auto_merge: false,
              required_contexts: [],
              description: `Deploy ${context.sha.substring(0, 7)}`,
            });
            return deployment.data.id;
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          command_timeout: 5m
          script: |
            set -euo pipefail
 
            APP_DIR="/var/www/akousa.net"
            IMAGE="ghcr.io/${{ github.repository }}:latest"
            SHA="${{ github.sha }}"
 
            echo "=== Deploy $SHA started at $(date) ==="
 
            # Tirer la nouvelle image
            docker pull "$IMAGE"
 
            # Lancer le nouveau conteneur sur un port alternatif
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # Health check
            echo "Running health check..."
            for i in $(seq 1 30); do
              if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
                echo "Health check passed (attempt $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "ERROR: Health check failed"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # Basculer le trafic
            sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
            sudo nginx -t && sudo nginx -s reload
 
            # Temps de grâce pour les requêtes en cours
            sleep 5
 
            # Arrêter l'ancien conteneur
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # Renommer et réinitialiser le port
            docker rename akousa-app-new akousa-app
            sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
            # Note : on ne reload pas Nginx ici parce que c'est le nom du conteneur
            # qui a changé, pas le port. Le prochain déploiement utilisera le bon port.
 
            # Enregistrer le déploiement
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # Nettoyage des anciennes images (plus de 7 jours)
            docker image prune -af --filter "until=168h"
 
            echo "=== Deploy complete at $(date) ==="
 
      - name: Update deployment status
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const deploymentId = ${{ steps.deployment.outputs.result }};
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deploymentId,
              state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
              environment_url: 'https://akousa.net',
              log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });
 
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Actor:*\n${{ github.actor }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "View Run" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
 
      - name: Alert on failure
        if: failure()
        run: |
          curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{
              "subject": "DEPLOY FAILED: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

Parcours du flux#

Quand je push sur main :

  1. Lint, Type Check et Test démarrent simultanément. Trois runners, trois jobs parallèles. Si l'un échoue, le pipeline s'arrête.
  2. Build s'exécute uniquement si les trois passent. Il valide que l'application compile et produit une sortie fonctionnelle.
  3. Docker builde l'image de production et la pousse sur ghcr.io. Multi-plateforme, cache de layers.
  4. Deploy se connecte en SSH au VPS, tire la nouvelle image, démarre un nouveau conteneur, le health-check, bascule Nginx et nettoie.
  5. Les notifications se déclenchent quel que soit le résultat. Slack reçoit le message. Les GitHub Deployments sont mis à jour. En cas d'échec, un email d'alerte part.

Quand j'ouvre une PR :

  1. Lint, Type Check et Test s'exécutent. Mêmes portes de qualité.
  2. Build s'exécute pour vérifier que le projet compile.
  3. Docker et Deploy sont sautés (les conditions if les limitent à la branche main uniquement).

Quand j'ai besoin d'un déploiement d'urgence (sauter les tests) :

  1. Cliquer sur « Run workflow » dans l'onglet Actions.
  2. Sélectionner skip_tests: true.
  3. Lint et typecheck s'exécutent toujours (tu ne peux pas les sauter — je ne me fais pas confiance à ce point).
  4. Les tests sont sautés, le build s'exécute, Docker builde, le déploiement se lance.

C'est mon workflow depuis deux ans. Il a survécu aux migrations de serveur, aux mises à jour majeures de Node.js, au remplacement de npm par pnpm, et à l'ajout de 15 outils sur ce site. Le temps total de bout en bout du push à la production : 3 minutes 40 secondes en moyenne. L'étape la plus lente est le build Docker multi-plateforme à ~90 secondes. Tout le reste est caché jusqu'à être quasi-instantané.

Leçons de deux ans d'itérations#

Je termine avec les erreurs que j'ai faites pour que tu n'aies pas à les faire.

Épingle les versions de tes actions. uses: actions/checkout@v4 ça va, mais pour la production, considère uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (le SHA complet). Une action compromise pourrait exfiltrer tes secrets. L'incident tj-actions/changed-files en 2025 a prouvé que ce n'est pas théorique.

Ne cache pas tout. J'ai un jour caché node_modules directement (pas juste le store pnpm) et j'ai passé deux heures à débugger un échec de build fantôme causé par des bindings natifs périmés. Cache le store du package manager, pas les modules installés.

Définis des timeouts. Chaque job devrait avoir timeout-minutes. Le défaut est de 360 minutes (6 heures). Si ton déploiement se bloque parce que la connexion SSH a coupé, tu ne veux pas le découvrir six heures plus tard quand tu as cramé tout ton budget mensuel de minutes.

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

Utilise concurrency judicieusement. Pour les PRs, cancel-in-progress: true est toujours correct — personne ne se soucie du résultat CI d'un commit qui a déjà été force-pushé. Pour les déploiements en production, mets-le à false. Tu ne veux pas qu'un commit rapide annule un déploiement en cours de rollout.

Teste ton fichier workflow. Utilise act (https://github.com/nektos/act) pour lancer les workflows en local. Ça ne détectera pas tout (les secrets ne sont pas disponibles, et l'environnement du runner diffère), mais ça détecte les erreurs de syntaxe YAML et les bugs de logique évidents avant de pusher.

Surveille tes coûts CI. Les minutes GitHub Actions sont gratuites pour les repos publics et peu chères pour les privés, mais ça s'additionne. Les builds Docker multi-plateformes sont 2x les minutes (une par plateforme). Les stratégies de test en matrice multiplient ton temps d'exécution. Garde un œil sur la page de facturation.

Le meilleur pipeline CI/CD est celui auquel tu fais confiance. La confiance vient de la fiabilité, de l'observabilité et de l'amélioration incrémentale. Commence par un simple pipeline lint-test-build. Ajoute Docker quand tu as besoin de reproductibilité. Ajoute le déploiement SSH quand tu as besoin d'automatisation. Ajoute les notifications quand tu as besoin de confiance. Ne construis pas le pipeline complet dès le premier jour — tu te tromperas d'abstractions.

Construis le pipeline dont tu as besoin aujourd'hui, et laisse-le grandir avec ton projet.

Articles similaires