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.
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#
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
- productionQuatre déclencheurs, chacun servant un but différent :
pushsurmainest ton déclencheur de déploiement en production. Code mergé ? On shippe.pull_requestlance tes vérifications CI sur chaque PR. C'est là que vivent le lint, les vérifications de types et les tests.scheduleest 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_dispatchte 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#
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 lintLes 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#
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.
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: 1Pourquoi 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 :
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 testfail-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#
- 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 :
- 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.
- 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 :
- Match exact : mêmes dépendances ET mêmes fichiers sources. Cache hit complet, build quasi-instantané.
- Match partiel (dépendances) : mêmes dépendances mais sources modifiées. Le build ne recompile que les fichiers modifiés.
- 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.
- 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=maxtype=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.
- 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 :
# 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#
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=maxDécortiquons les décisions importantes ici.
GitHub Container Registry (ghcr.io)#
J'utilise ghcr.io plutôt que Docker Hub pour trois raisons :
- L'authentification est gratuite.
GITHUB_TOKENest automatiquement disponible dans chaque workflow — pas besoin de stocker des identifiants Docker Hub. - 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.
- Visibilité. Les images sont liées à ton repo dans l'interface GitHub. Tu les vois dans l'onglet Packages.
Builds multi-plateformes#
platforms: linux/amd64,linux/arm64Cette 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#
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#
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 :
#!/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 :
script: |
cd /var/www/akousa.net && ./deploy.shC'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.
# ecosystem.config.js a déjà :
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 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 :
- 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-envC'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 :
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /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 :
#!/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 :
# 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 :
docker compose pull
docker compose up -d --remove-orphansDocker 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#
# 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#
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.netMê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 :
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.comPas 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 :
{
"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 :
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#
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 :
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 linttypecheck— Pas d'erreurs de typestest— Tous les tests passentbuild— 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#
- 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 :
- 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é :
- 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.
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 }}"
}' || trueParcours du flux#
Quand je push sur main :
- Lint, Type Check et Test démarrent simultanément. Trois runners, trois jobs parallèles. Si l'un échoue, le pipeline s'arrête.
- Build s'exécute uniquement si les trois passent. Il valide que l'application compile et produit une sortie fonctionnelle.
- Docker builde l'image de production et la pousse sur ghcr.io. Multi-plateforme, cache de layers.
- Deploy se connecte en SSH au VPS, tire la nouvelle image, démarre un nouveau conteneur, le health-check, bascule Nginx et nettoie.
- 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 :
- Lint, Type Check et Test s'exécutent. Mêmes portes de qualité.
- Build s'exécute pour vérifier que le projet compile.
- Docker et Deploy sont sautés (les conditions
ifles limitent à la branchemainuniquement).
Quand j'ai besoin d'un déploiement d'urgence (sauter les tests) :
- Cliquer sur « Run workflow » dans l'onglet Actions.
- Sélectionner
skip_tests: true. - Lint et typecheck s'exécutent toujours (tu ne peux pas les sauter — je ne me fais pas confiance à ce point).
- 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.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestUtilise 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.