GitHub Actions : déploiement Docker Compose
Un hôte unique qui fait tourner une vingtaine de services Docker Compose, un dépôt Git qui contient un dossier par stack : la question du déploiement se pose dès la deuxième modification. Se connecter en SSH, faire un git pull, relancer docker compose up -d dans le bon dossier fonctionne, mais l'opération est manuelle, oubliable et non tracée. Cet article décrit un workflow GitHub Actions qui déploie, à chaque push sur la branche principale, uniquement les stacks modifiées, sans orchestrateur et sans agent installé sur l'hôte.
Modèle de déploiement
Push-based contre pull-based
Le GitOps décrit l'état désiré dans Git et confie à un mécanisme automatique l'alignement de l'état réel. En modèle pull-based (Argo CD, Flux), un agent sur la cible surveille Git et réconcilie en continu : les credentials restent sur la cible et toute dérive est corrigée. En modèle push-based, le pipeline CI se connecte à la cible et applique les changements : il détient un accès à la cible (une clé SSH) et ne corrige aucune dérive, seul un push déclenchant une action.
Argo CD et Flux sont des contrôleurs Kubernetes : sans cluster, ils n'ont pas d'objet sur lequel travailler. Pour un hôte unique piloté par Docker Compose, le modèle push-based est le plus direct, avec un workflow GitHub Actions comme unique point d'entrée vers la production. Conséquence de l'absence de réconciliation : une modification faite à la main sur l'hôte persiste jusqu'au prochain déploiement de la stack concernée.
Quand un orchestrateur est superflu
Docker Swarm et Kubernetes apportent la répartition sur plusieurs nœuds, la bascule en cas de panne et les rolling updates. Sur un seul nœud, la bascule n'a pas de cible : la panne de l'hôte arrête tout, orchestrateur compris. Il reste un coût opérationnel pour un bénéfice limité aux rolling updates. Docker Compose couvre alors le besoin, au prix d'une brève interruption à chaque recréation de conteneur.
Vue d'ensemble
Le dépôt contient un dossier par stack (proxy/compose.yml, media/compose.yml, wiki/compose.yml...) et deux workflows : deploy.yml sur push, validate.yml sur pull request. Il est cloné une fois sur l'hôte, par exemple dans /srv/stacks. Le workflow ne transfère aucun fichier : il ordonne à l'hôte de se mettre à jour depuis Git, puis de relancer les stacks concernées.
Calculer les stacks modifiées
Le diff entre deux pushes
L'événement push fournit github.event.before, le SHA du dernier commit de la branche avant le push, et github.sha, celui d'après. git diff --name-only entre les deux liste les fichiers modifiés ; le premier segment du chemin donne le dossier, donc la stack.
Trois cas particuliers :
- Premier push d'une branche :
beforevaut quarante zéros, SHA qui ne désigne aucun commit. Le repli surHEAD~1limite le diff au dernier commit. - Historique complet requis :
actions/checkoutne récupère par défaut qu'un commit (fetch-depth: 1). Sansfetch-depth: 0, le commitbeforeest absent du clone etgit difféchoue. - Dossier supprimé : un dossier présent dans le diff mais sans
compose.ymlaprès le push correspond à une stack retirée du dépôt. Elle doit être arrêtée pardocker compose down, faute de quoi ses conteneurs tournent indéfiniment sans définition dans Git. Vérifier quecompose.ymlexistait dans le commitbefore(git cat-file -e) évite de classer en suppression des dossiers qui n'ont jamais été des stacks, comme.github/.
Filtrer les fichiers non déployables
Tout fichier modifié dans le dossier d'une stack ne justifie pas un redéploiement. Une stack dont seule la documentation (*.md) a changé peut être écartée : Compose ne recréerait aucun conteneur, mais up -d n'est pas pour autant neutre. Il démarre les conteneurs arrêtés de la stack, y compris ceux qu'un outil de scale-to-zero comme Sablier a mis en veille, et --build relance la construction des images locales. À l'inverse, si un site de documentation est construit à partir des .md de tout le dépôt, sa stack doit être ajoutée à la liste dès qu'un .md change, même hors de son dossier, faute de quoi le site ne se reconstruit pas :
# CHANGED : fichiers modifiés (git diff --name-only), TARGET : commit à déployer
for d in $(printf '%s\n' "$CHANGED" | grep / | cut -d/ -f1 | sort -u || true); do
if git cat-file -e "$TARGET:$d/compose.yml" 2>/dev/null; then
# Seuls des .md ont changé dans ce dossier : la stack n'est pas relancée
[ "$d" != docs ] && ! printf '%s\n' "$CHANGED" | grep "^$d/" | grep -qv '\.md$' && continue
UP="$UP $d"
fi
done
# Site de documentation construit à partir de tous les .md du dépôt
printf '%s\n' "$CHANGED" | grep -qE '(^|/)[^/]+\.md$' && case " $UP " in *" docs "*) ;; *) UP="$UP docs";; esac
Redéploiement manuel
Le déclencheur workflow_dispatch accepte des inputs typés (string, choice, boolean, number, environment). Un input stack permet de redéployer une stack sans commit, cas utile après une panne ou pour forcer la recréation d'un conteneur :
# Redéployer la stack "wiki" depuis un poste local
gh workflow run deploy.yml -f stack=wiki
La valeur de l'input est contrôlée par l'utilisateur : elle transite par une variable d'environnement plutôt que par une interpolation ${{ }} directement dans le script, ce qui empêche l'injection de commandes shell, et elle est validée contre l'existence d'un compose.yml.
Sérialiser les déploiements
Deux pushes rapprochés lancent deux runs. Exécutés en parallèle, ils lanceraient des git pull et des docker compose up concurrents sur le même hôte. Le bloc concurrency place les runs d'un même groupe en file :
concurrency:
group: deploy-production
cancel-in-progress: false # ne jamais interrompre un déploiement en cours
queue: max # conserver tous les runs en attente
cancel-in-progress: true interromprait un déploiement au milieu de la boucle, laissant une partie des stacks à jour et l'autre non. Avec false, le run en cours se termine. Le comportement par défaut (queue: single) ne conserve toutefois qu'un seul run en attente : un troisième push annule le deuxième, encore en attente. Or chaque run ne déploie que le diff before..sha de son propre push : les stacks modifiées par le run annulé ne sont jamais déployées. queue: max conserve jusqu'à 100 runs en attente, dans l'ordre d'arrivée ; la combinaison avec cancel-in-progress: true est refusée à la validation du workflow. Une alternative supprime la cause du problème plutôt que l'annulation : calculer le diff sur l'hôte, depuis le dernier déploiement réussi (voir plus bas). Un run annulé n'a alors plus de conséquence, puisque le suivant reprend tout ce qui n'a pas été déployé.
Atteindre un hôte privé
L'hôte n'expose généralement pas SSH sur Internet. Deux options :
- Runner auto-hébergé sur le réseau de l'hôte (voir Self-hosted runner) : aucune connexion entrante, mais une machine de plus à maintenir, et un runner qui exécute le code du dépôt avec un accès réseau privilégié.
- Runner hébergé qui rejoint un réseau overlay (Tailscale, WireGuard, Netbird) le temps du job.
tailscale/github-actioncrée un nœud éphémère, supprimé par le serveur de coordination à la fin du run ; un tag dédié (tag:ci) permet de restreindre par ACL ce nœud au seul port SSH de l'hôte.
La clé SSH privée est stockée en secret GitHub. StrictHostKeyChecking=accept-new accepte la clé d'hôte au premier contact et refuse une clé modifiée ; le runner étant neuf à chaque run, chaque connexion est un premier contact. Injecter la clé d'hôte attendue dans ~/.ssh/known_hosts depuis un secret supprime cette confiance au premier usage. Le script distant est transmis par un heredoc à bash -s : le délimiteur entre apostrophes (<<'REMOTE') empêche toute expansion côté runner, et les variables sont passées en préfixe de la commande SSH.
Déroulé sur l'hôte
Ordre des opérations
Les stacks supprimées sont arrêtées avant le git pull : après la mise à jour, leur compose.yml a disparu et Compose ne sait plus quels conteneurs arrêter. git pull --ff-only refuse ensuite toute fusion : si le clone a divergé (commit local, historique réécrit), le déploiement échoue au lieu de produire un état qui ne correspond à aucun commit. Enfin, docker compose up -d --build --remove-orphans reconstruit les images locales et supprime les conteneurs des services retirés du fichier.
Dépendances entre stacks
Une stack peut déclarer un volume external: true créé par une autre, typiquement une stack de sauvegarde qui monte les volumes de toutes les autres. Compose refuse de démarrer si ce volume n'existe pas : la stack propriétaire doit démarrer avant la consommatrice. Le diff ne fournissant aucun ordre, deux listes explicites encadrent la boucle.
Chaque compose.yml porte un name: explicite. Le nom de projet préfixe les volumes et réseaux (media_library) ; sans name:, il dérive du nom du dossier, et un renommage de dossier crée de nouveaux volumes vides tout en rendant introuvables les références external des autres stacks.
Nettoyage sans casse
Les up --build successifs accumulent images et cache de build. docker system prune supprime aussi tous les conteneurs arrêtés et les réseaux sans conteneur actif. Or un conteneur arrêté n'est pas forcément un déchet : arrêt volontaire, ou mise en veille par un outil de scale-to-zero comme Sablier. Conteneur supprimé, docker start n'a plus de cible ; réseau supprimé, le conteneur référence un identifiant de réseau inexistant et refuse de démarrer. Le constat vaut pour docker container prune et docker network prune pris isolément. Deux commandes restent sûres :
# Images non référencées par un conteneur, y compris arrêté
docker image prune -af
# Cache BuildKit
docker builder prune -af
Une image utilisée par un conteneur arrêté n'est pas supprimée par image prune, ce qui préserve la possibilité de redémarrer ce conteneur.