Chapitre 1125 minIntermédiaire
Déployer automatiquement une application (CD)
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- choisir une méthode de déploiement adaptée à la cible ;
- écrire un script de déploiement qui vérifie et revient en arrière ;
- assembler un pipeline complet jusqu'à la production ;
- vérifier un déploiement par des tests de fumée.
4 exercices corrigés1 schéma
Déployer automatiquement, c'est laisser le pipeline installer lui-même la nouvelle version dans un environnement, une fois les vérifications passées, par une procédure écrite et rejouable : récupérer l'artefact, appliquer les migrations, redémarrer le service, vérifier qu'il répond. Ce chapitre présente les façons de déployer (plateforme, serveur en SSH, site statique), écrit le script de déploiement du serveur de sorbier-frais, assemble le pipeline complet jusqu'à la production, et montre comment vérifier un déploiement.
Les façons de déployer
| Cible | Comment le pipeline déploie | Exemple | Pour qui |
|---|---|---|---|
| Plateforme d'hébergement (PaaS) | Elle surveille le dépôt, ou le pipeline appelle une URL de déclenchement (deploy hook) | Render, Railway, Scalingo, Clever Cloud | Une application Web sans serveur à administrer |
| Serveur (VPS ou machine virtuelle interne) | Connexion SSH, puis docker compose pull et up | Le serveur de recette de Sorbier Logistique | Le cas le plus formateur en BTS, SLAM comme SISR |
| Hébergement statique | Envoi des fichiers construits | GitHub Pages, Netlify | Documentation, site vitrine, application React sans serveur |
| Cluster Kubernetes | kubectl apply, Helm, ou un agent GitOps | Argo CD, Flux | Les grandes infrastructures |
| Serveur Windows avec IIS | Web Deploy, ou copie et redémarrage du site par PowerShell distant | Une application ASP.NET interne | Les environnements Microsoft |
Scalingo et Clever Cloud sont des plateformes françaises : les données restent hébergées en France, un argument qui compte pour une administration ou une entreprise attentive à la souveraineté de ses données.
Le script de déploiement du serveur
Sur le serveur de recette, le dossier /opt/sorbier-frais contient le fichier Compose (l'infrastructure as code en bref), les fichiers de secrets .env, et un script deployer.sh que le pipeline appelle avec l'étiquette de l'image :
#!/usr/bin/env bash
# Déploie une version de sorbier-frais : ./deployer.sh sha-3f9c2e1
set -euo pipefail
VERSION="${1:?Usage : deployer.sh <étiquette>}"
if [[ ! "$VERSION" =~ ^(sha-[0-9a-f]{7}|[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "Étiquette invalide : $VERSION" >&2
exit 2
fi
cd /opt/sorbier-frais
PRECEDENTE=$(cat version-courante 2>/dev/null || true)
export VERSION
echo "Déploiement de $VERSION (précédente : ${PRECEDENTE:-aucune})"
docker compose pull api
if [[ "${MIGRER:-oui}" == "oui" ]]; then
docker compose run --rm api npm run migrate
fi
docker compose up -d api
for _ in $(seq 1 20); do
if curl --fail --silent --output /dev/null http://127.0.0.1:3000/health; then
echo "$VERSION" > version-courante
echo "Version $VERSION en service"
exit 0
fi
sleep 3
done
echo "La version $VERSION ne répond pas : retour à ${PRECEDENTE:-aucune}" >&2
if [[ -n "$PRECEDENTE" ]]; then
VERSION="$PRECEDENTE" docker compose up -d api
fi
exit 1
| Partie | Rôle |
|---|---|
set -euo pipefail | Le script s'arrête à la première commande en échec, à la première variable non définie, et sur une erreur au milieu d'un tube |
Le test =~ | N'accepte qu'une étiquette de la forme sha- suivie de 7 caractères hexadécimaux, ou un numéro de version : rien d'autre ne peut être injecté dans les commandes |
version-courante | Un fichier qui mémorise la version en service, pour pouvoir y revenir |
docker compose pull api | Télécharge l'image de la nouvelle version, sans arrêter l'ancienne |
docker compose run --rm api npm run migrate | Applique les migrations avec le code de la nouvelle version, dans un conteneur temporaire ; si elles échouent, le script s'arrête et l'ancienne version continue de tourner. MIGRER=non saute cette étape, pour un retour arrière (chapitre 12) |
docker compose up -d api | Remplace le conteneur par la nouvelle version |
La boucle curl | Attend jusqu'à 60 secondes que la route de santé réponde |
| La fin du script | Si la nouvelle version ne répond pas, redémarre la précédente et renvoie un code d'erreur : le job du pipeline devient rouge |
Ce script est versionné dans le dépôt (dossier deploy/) et installé sur le serveur avec le fichier Compose, par Ansible ou par une étape du pipeline. Il fait une courte coupure, le temps du redémarrage du conteneur : quelques secondes, acceptables pour une application interne. Le chapitre 12 montre comment l'éviter.
Le pipeline complet de sorbier-frais
Ce workflow réunit tout le cours : vérifications en parallèle, construction de l'image, déploiement automatique en recette avec test de fumée, puis production après approbation.
name: CI/CD
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ci-cd-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
IMAGE: ghcr.io/sorbier/sorbier-frais
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test -- --ci
image:
needs: [lint, tests]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
tag: ${{ steps.etiquette.outputs.tag }}
steps:
- uses: actions/checkout@v7
- id: etiquette
run: echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v4
- if: github.event_name == 'push'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ env.IMAGE }}:${{ steps.etiquette.outputs.tag }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-recette:
needs: image
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment:
name: recette
url: https://recette.sorbier.example
concurrency:
group: deploiement-recette
cancel-in-progress: false
steps:
- name: Préparer la connexion SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Déployer
run: ssh deploy@recette.sorbier.example "/opt/sorbier-frais/deployer.sh ${{ needs.image.outputs.tag }}"
- name: Test de fumée
run: curl --fail --silent --show-error --retry 5 --retry-delay 3 --retry-all-errors https://recette.sorbier.example/health
deploy-production:
needs: [image, deploy-recette]
runs-on: ubuntu-latest
environment:
name: production
url: https://frais.sorbier.example
concurrency:
group: deploiement-production
cancel-in-progress: false
steps:
- name: Préparer la connexion SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Déployer
run: ssh deploy@frais.sorbier.example "/opt/sorbier-frais/deployer.sh ${{ needs.image.outputs.tag }}"
- name: Test de fumée
run: curl --fail --silent --show-error --retry 5 --retry-delay 3 --retry-all-errors https://frais.sorbier.example/health
Ce qui se passe :
- Pour une pull request :
lintettestsen parallèle, puisimageconstruit l'image sans la pousser.deploy-recetteest ignoré (if), doncdeploy-productionaussi. Un nouveaupushsur la branche annule l'exécution précédente. - Après une fusion dans
main: mêmes vérifications, puis l'imagesha-…est poussée, déployée en recette et testée.deploy-productionattend alors l'approbation d'un relecteur de l'environnementproduction. Surmain, une exécution en cours n'est jamais interrompue ; si plusieurs fusions arrivent pendant ce temps, seule la plus récente attend son tour, ce qui revient à déployer le dernier état demain. - Les secrets
DEPLOY_SSH_KEYet la variableDEPLOY_KNOWN_HOSTSexistent dans chaque environnement, avec des valeurs différentes : la clé de recette ne permet pas de déployer en production.
Promouvoir la même image
La production reçoit la même étiquette que la recette, transmise par la sortie needs.image.outputs.tag. L'image n'est jamais reconstruite : ce qui a été testé en recette est exactement ce qui tourne en production. On parle de promotion d'un artefact d'un environnement à l'autre.
Pour une garantie encore plus forte, on déploie l'image par son digest (ghcr.io/sorbier/sorbier-frais@sha256:…), que docker/build-push-action fournit dans sa sortie digest : une étiquette pourrait en théorie être réaffectée, un digest jamais.
Vérifier après le déploiement
Un déploiement réussi n'est pas une commande qui rend la main sans erreur : c'est un service qui fonctionne. Plusieurs niveaux de vérification se complètent :
| Vérification | Ce qu'elle prouve | Exemple |
|---|---|---|
| Route de santé (liveness) | Le processus tourne et répond | GET /health renvoie 200 |
| Route de disponibilité (readiness) | Le service peut travailler : base joignable, configuration chargée | GET /health/ready vérifie la connexion MySQL |
| Route de version | La bonne version est en ligne | GET /version renvoie {"commit": "3f9c2e1"}, comparé à l'étiquette déployée |
| Tests de fumée fonctionnels | Les parcours essentiels marchent | Se connecter avec un compte de test, lister les notes |
| Supervision | Le service reste sain dans la durée | Taux d'erreurs, temps de réponse, alertes (supervision) |
Le pipeline exécute les trois premières ; la supervision prend le relais. Si le taux d'erreurs monte dans les minutes qui suivent un déploiement, le premier suspect est ce déploiement, et la première action est le retour arrière (chapitre 12).
Déployer sur une plateforme (PaaS)
Les plateformes d'hébergement simplifient le déploiement : on relie le dépôt, et la plateforme construit et déploie à chaque push sur la branche choisie. Pour garder la main sur le moment du déploiement, on désactive le déploiement automatique de la plateforme et on laisse le pipeline appeler l'URL de déclenchement après les tests :
deploy:
needs: [lint, tests]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: recette
steps:
- name: Déclencher le déploiement
env:
DEPLOY_HOOK: ${{ secrets.DEPLOY_HOOK }}
run: curl --fail --silent --show-error -X POST "$DEPLOY_HOOK"
L'URL de déclenchement est un secret : quiconque la connaît peut lancer un déploiement. La plateforme fait ensuite son propre contrôle de santé, et garde l'ancienne version si la nouvelle ne répond pas.
Publier un site statique sur GitHub Pages
Pour une documentation ou un site sans serveur, GitHub Pages héberge gratuitement les fichiers construits. Dans Settings, Pages, choisis la source GitHub Actions, puis :
name: Documentation
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run docs:build
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: docs/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploiement.outputs.page_url }}
steps:
- id: deploiement
uses: actions/deploy-pages@v5
upload-pages-artifact empaquette le dossier construit, deploy-pages le publie ; id-token: write permet à l'action de prouver son identité à GitHub Pages par OIDC, sans secret. Pour l'E6, publier ainsi la documentation technique de ton projet à chaque fusion montre qu'elle suit le code.
Aller jusqu'au déploiement continu
Retirer l'approbation avant la production, c'est passer de la livraison continue au déploiement continu. Ce n'est raisonnable que si :
- les tests automatiques couvrent les parcours essentiels et l'équipe leur fait confiance ;
- chaque déploiement est petit (une pull request, pas trois semaines de travail) ;
- la supervision détecte vite une dégradation, et le retour arrière est automatique ou se fait en une commande ;
- les fonctionnalités risquées sont cachées derrière des interrupteurs de fonctionnalité (feature flags) qu'on peut couper sans redéployer (chapitre 12).
Pour un projet de BTS, la livraison continue (recette automatique, production approuvée) est le bon niveau : c'est ce que tu sauras expliquer et justifier.
Exercices
Exercice 1 sur 4 Lire un déploiement en échec Réussi À revoir
Après la fusion de la pull request « Export PDF des notes de frais », le job deploy-recette échoue :
Run ssh deploy@recette.sorbier.example "/opt/sorbier-frais/deployer.sh sha-b7e21f4"
Déploiement de sha-b7e21f4 (précédente : sha-3f9c2e1)
api Pulled
Batch 5 run: 1 migrations
Container sorbier-frais-api-1 Recreate
Container sorbier-frais-api-1 Recreated
Container sorbier-frais-api-1 Starting
Container sorbier-frais-api-1 Started
La version sha-b7e21f4 ne répond pas : retour à sha-3f9c2e1
Container sorbier-frais-api-1 Recreate
Container sorbier-frais-api-1 Recreated
Container sorbier-frais-api-1 Starting
Container sorbier-frais-api-1 Started
Error: Process completed with exit code 1.
- Que s'est-il passé, étape par étape ? Quelle version tourne maintenant en recette ?
- Le job
deploy-productionva-t-il s'exécuter ? - Sur le serveur,
docker compose logs apimontre, pour la versionsha-b7e21f4:Error: Cannot find module 'pdfkit'. Pourtant, les tests de la pull request passaient, y compris ceux de l'export PDF. Explique. - Quel job ajouté au pipeline aurait détecté le problème avant le déploiement ?
Voir la réponseMasquer la réponse de l'exercice 1
- L'image
sha-b7e21f4a été téléchargée, une migration a été appliquée, le conteneur a été recréé avec la nouvelle version. Pendant 60 secondes, la route/healthn'a jamais répondu : le script a redémarré la version précédente,sha-3f9c2e1, et renvoyé le code 1. La recette fait tourner sha-3f9c2e1, l'ancienne version, avec la nouvelle migration déjà appliquée (d'où l'intérêt des migrations par expansion, compatibles avec l'ancien code). - Non :
deploy-productiondépend dedeploy-recette, qui a échoué ; il est ignoré. pdfkita été installé comme dépendance de développement (npm install --save-dev pdfkit). Dans la CI,npm ciinstalle toutes les dépendances : les tests passent. Mais l'image de production est construite avecnpm ci --omit=dev:pdfkiten est absent, et l'application plante au démarrage. Correction :npm install --save pdfkit(dépendance de production), nouvelle pull request.- Un job qui construit l'image et la démarre pour un test de fumée, avant tout déploiement (construire et tester l'image dans la CI). Il aurait échoué dès la pull request.
Tu avais trouvé ?
Exercice 2 sur 4 Un déploiement trop pressé Réussi À revoir
jobs:
tests:
runs-on: ubuntu-latest
steps: [...]
deploy:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: ./scripts/deployer.sh
- Un commit dont les tests échouent est poussé sur
main. Est-il déployé ? - Corrige le workflow.
Voir la réponseMasquer la réponse de l'exercice 2
- Oui : sans
needs,deploydémarre en même temps quetests, sans l'attendre. Le code est déployé pendant que les tests échouent. De plus, il manque lecheckout: le script n'est pas sur le runner, et le job échouerait de toute façon. - On chaîne les jobs et on récupère le code :
jobs:
tests:
runs-on: ubuntu-latest
steps: [...]
deploy:
needs: tests
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: recette
steps:
- uses: actions/checkout@v7
- run: ./scripts/deployer.sh
needs: tests chaîne « tester, puis déployer ». La condition sur l'événement est une précaution : avec un déclencheur workflow_dispatch ajouté plus tard, un lancement manuel sur main déploierait sans passer par une fusion.
Tu avais trouvé ?
Exercice 3 sur 4 Un test de fumée qui ne teste rien Réussi À revoir
Le test de fumée de Hugo est curl https://recette.sorbier.example/health. Après un déploiement raté, l'API renvoyait une erreur 502 du serveur frontal, et l'étape était pourtant verte. Explique et corrige.
Voir la réponseMasquer la réponse de l'exercice 3
Sans option, curl renvoie le code 0 dès qu'il reçoit une réponse HTTP, quel que soit le statut : une page d'erreur 502 est un succès pour lui. Il faut --fail (ou -f), qui renvoie un code d'erreur pour un statut 4xx ou 5xx :
curl --fail --silent --show-error --retry 5 --retry-delay 3 --retry-all-errors https://recette.sorbier.example/health
--retry et --retry-all-errors laissent au service le temps de démarrer. Une vérification plus forte compare aussi la version renvoyée par /version à l'étiquette déployée.
Tu avais trouvé ?
Exercice 4 sur 4 Quelle version tourne ? Réussi À revoir
Le pipeline a déployé sha-3f9c2e1 en recette lundi, puis sha-b7e21f4 mardi (échec et retour arrière, comme dans le premier exercice), puis sha-c90a2d8 mercredi (succès). Que contient le fichier version-courante après chaque déploiement ? Si le déploiement de jeudi échoue, vers quelle version le script reviendra-t-il ?
Voir la réponseMasquer la réponse de l'exercice 4
- Lundi :
sha-3f9c2e1. - Mardi : toujours
sha-3f9c2e1; le script n'écrit le fichier qu'après un contrôle de santé réussi, et la version de mardi n'en a pas eu. - Mercredi :
sha-c90a2d8. - Jeudi, en cas d'échec : retour à
sha-c90a2d8, la dernière version qui a fonctionné.
Tu avais trouvé ?