Déploiement Continu Sans Temps d’Arrêt avec GitHub Actions et SSH
Mettre en place un pipeline CI/CD automatisé qui déploie sans interruption de service.
1. Anatomie d’un pipeline de déploiement sans coupure
Un pipeline de déploiement qui se contente de lancer docker compose up à chaque push sur main n’est pas un pipeline de déploiement, c’est un générateur de panne programmée. L’écart entre « ça compile » et « ça se déploie en toute sécurité » se comble par une séquence d’étapes concrètes et vérifiables : construire et taguer une image immuable, la pousser vers un registre, se connecter en SSH à l’hôte cible, récupérer la nouvelle image, exécuter les migrations si nécessaire, échanger les conteneurs derrière un healthcheck, et vérifier que la nouvelle version sert réellement du trafic avant de déclarer le succès.
La décision de conception la plus importante est d’utiliser des images immuables, taguées par digest, plutôt que de déployer en re-tirant un tag flottant :latest. Taguez chaque build avec le SHA Git (et éventuellement le numéro de run), poussez ce tag exact, et faites en sorte que le script de déploiement côté serveur tire ce tag précis plutôt que latest. Cela rend chaque déploiement traçable jusqu’à un commit exact, transforme le rollback en une modification d’une seule ligne (redéployer le tag du SHA précédent), et élimine toute une classe de confusions du type « ça marchait en CI mais le serveur a tiré une image différente de celle que je croyais ».
GitHub Actions gère naturellement la moitié build-et-push grâce à son intégration Docker Buildx et à GHCR (GitHub Container Registry), qui ne nécessite aucun compte de registre séparé et s’intègre aux permissions existantes du dépôt. La moitié déploiement — se connecter réellement au VPS et échanger les conteneurs — se fait au mieux via SSH avec une clé de déploiement dédiée stockée en tant que secret du dépôt, en invoquant un script de déploiement qui vit déjà sur le serveur plutôt que d’intégrer la logique de déploiement directement dans le YAML du workflow.
2. Migrations de schéma sans rupture
Les migrations constituent la partie la plus risquée de tout déploiement automatisé, car contrairement à l’échange d’un conteneur sans état, une migration modifie un état partagé que l’ancienne version en cours d’exécution et la nouvelle version entrante touchent simultanément pendant la fenêtre de rollout. Exécuter les migrations comme étape de build, ou dans le code de démarrage de l’application elle-même, signifie que chaque réplique qui se lance applique la migration de façon concurrente, ce qui, au-delà des changements de schéma les plus simples, risque une contention de verrous ou une application partielle.
Le schéma sûr consiste à exécuter les migrations comme étape distincte, à exécution unique, dans le script de déploiement, avant que les nouveaux conteneurs ne commencent à servir du trafic, en utilisant un outil qui prend en charge le verrouillage consultatif (advisory locking) pour qu’une seconde invocation concurrente attende plutôt que de corrompre l’état. Prisma Migrate, le migrateur de Drizzle et node-pg-migrate le prennent tous en charge ; le script de déploiement doit exécuter migrate deploy (ou équivalent) comme une commande ponctuelle contre la base de production, vérifier son code de sortie, et abandonner entièrement le déploiement en cas d’échec, plutôt que de poursuivre l’échange de conteneurs sur un schéma à moitié migré.
Pour les changements de schéma véritablement cassants (renommer une colonne, changer un type, supprimer quelque chose que l’ancien code lit encore), la seule approche sûre lors d’un déploiement progressif consiste à découper le changement en phases rétrocompatibles : ajouter la nouvelle colonne à côté de l’ancienne et écrire dans les deux en parallèle côté application, backfiller les données, basculer les lectures vers la nouvelle colonne lors d’un déploiement suivant, puis supprimer l’ancienne colonne lors d’un troisième déploiement une fois certain qu’aucune version en cours d’exécution ne la référence plus. C’est plus lent, mais cela permet aux anciennes et nouvelles versions de l’application de coexister pendant la fenêtre d’échange sans qu’aucune des deux ne plante sur un schéma inattendu.
3. Échange de conteneurs sans coupure
docker compose up -d à lui seul arrête l’ancien conteneur avant que le nouveau ne soit confirmé en bonne santé, créant une véritable fenêtre où le reverse proxy n’a nulle part où router les requêtes. Le flag --wait (Compose v2.17+) change cela : Compose démarre le nouveau conteneur, attend qu’il se déclare en bonne santé via son healthcheck Docker, et ne termine la commande qu’ensuite — combiné à un reverse proxy configuré pour router vers le conteneur par nom DNS de service plutôt que par une IP fixe, cela vous donne un échange progressif où l’ancien conteneur continue de servir jusqu’à ce que le nouveau ait fait ses preuves.
C’est précisément pourquoi la qualité des healthchecks abordée dans l’article précédent de cette série compte autant ici : l’utilité de --wait est entièrement bornée par le fait que le healthcheck sur lequel il attend reflète réellement la capacité de l’application à servir du trafic réel. Un healthcheck qui vérifie simplement que le processus tourne donne un échange qui semble sûr mais ne l’est pas — le nouveau conteneur est marqué en bonne santé et commence à recevoir du trafic avant même que son pool de connexions à la base de données n’ait chauffé ou que son planificateur de jobs en arrière-plan ne se soit initialisé.
4. Smoke tests et rollback automatisé
Un déploiement qui se termine sans erreur n’est pas la même chose qu’un déploiement réussi — le conteneur peut être démarré, et même passer son healthcheck, tandis qu’une route critique pour l’utilisateur renvoie silencieusement des 500 à cause d’une variable d’environnement manquante ou d’une migration qui ne s’est pas appliquée comme prévu. Une étape de smoke test après l’échange de conteneurs, interrogeant deux ou trois endpoints réels (la page d’accueil, un flux de connexion, une route API clé) et vérifiant les codes de statut et la forme de réponse attendus, capture cette classe d’échec avant les utilisateurs réels.
Lorsque le smoke test échoue, le script de déploiement doit revenir en arrière automatiquement plutôt que de laisser un déploiement cassé en production pendant qu’un humain investigue. Comme chaque image est taguée par SHA Git, le rollback est mécaniquement simple : enregistrer le SHA précédemment déployé avant de démarrer le nouveau déploiement, et en cas d’échec du smoke test, rejouer la même logique d’échange de conteneurs contre ce tag précédent. Cela transforme un mauvais déploiement en un bref incident corrigé automatiquement, plutôt qu’en une panne prolongée en attente qu’un humain remarque une alerte Slack.
La notification compte autant que la logique de rollback elle-même. Un pipeline de déploiement qui échoue silencieusement dans un log GitHub Actions que personne ne surveille vaut à peine mieux que l’absence de pipeline. Branchez un webhook (Slack, Discord, une simple étape e-mail) sur les chemins d’échec comme de succès, afin qu’une migration échouée ou un smoke test raté remonte immédiatement plutôt que d’être découvert quand un utilisateur signale que le site est cassé.
5. Sécuriser les identifiants de déploiement
Un workflow GitHub Actions disposant d’un accès SSH à la production constitue, dans les faits, un second jeu d’identifiants de production qui mérite le même niveau de vigilance que n’importe quel autre. Générez une paire de clés ed25519 dédiée, utilisée uniquement pour les déploiements, jamais réutilisée depuis la clé personnelle d’un développeur, et restreignez-la côté serveur avec une commande forcée dans authorized_keys si la surface d’action de votre script de déploiement est assez étroite pour s’exprimer ainsi — cela limite ce que la clé peut faire même si le stockage des secrets GitHub venait à être compromis.
Stockez la clé privée, l’hôte et tous les identifiants de registre exclusivement en tant que secrets chiffrés GitHub Actions, jamais dans le dépôt lui-même, et scopez-les à l’environnement (fonctionnalité Environments de GitHub) plutôt qu’à l’ensemble du dépôt lorsque vous avez à la fois des cibles staging et production. Activer des reviewers requis sur l’environnement de production ajoute une porte manuelle à un pipeline par ailleurs entièrement automatisé, ce qui vaut la friction pour la branche la plus critique d’un projet maintenu en solo.
6. Synthèse des bonnes pratiques de production
Un pipeline de déploiement ne mérite l’étiquette « sans coupure » que lorsque chaque pièce fonctionne ensemble : des images immuables taguées par SHA rendent chaque déploiement traçable et le rollback trivial ; les migrations s’exécutent comme une étape verrouillée et à exécution unique, avec verrouillage consultatif, pour que les répliques concurrentes ne se fassent jamais concurrence ; l’échange de conteneurs attend un healthcheck qui reflète réellement la disponibilité plutôt que la simple survie du processus ; et un smoke test après échange, avec rollback automatique, capture les échecs qui échappent à un statut de conteneur pourtant en bonne santé.
Construisez cela progressivement plutôt que d’un seul coup. Commencez par des images taguées par SHA et un script de déploiement SSH basique, ajoutez l’échange conditionné par healthcheck (--wait) une fois que votre stack Compose dispose de véritables healthchecks (voir l’article précédent de cette série), puis superposez en dernier la sécurité des migrations et le rollback automatisé, une fois que vous faites suffisamment confiance au reste du pipeline pour le laisser décider sans surveiller chaque exécution.
Le bénéfice concret est un déploiement qu’un développeur solo peut déclencher en mergeant sur main, laisser tourner, et à qui il peut faire confiance pour réussir proprement ou revenir en arrière tout seul avec une notification — transformant le déploiement d’un événement stressant et supervisé manuellement en un non-événement ennuyeux et fiable, ce qui est exactement ce qu’une infrastructure de production devrait être.

