Watchtower : déployer après chaque git push, sans webhook
Comment un serveur privé, Portainer en édition gratuite et une image GHCR se mettent à jour seuls à chaque push sur main, sans ouvrir un seul port.

Ce blog tourne dans un conteneur Docker, sur un serveur géré avec Portainer. Pendant des mois, chaque article publié se terminait de la même façon : push sur GitHub, attente du build, ouverture de Portainer, « Recreate », case « Re-pull image » cochée. Trente secondes de clics, à chaque fois, et parfois un oubli.
Watchtower supprime cette étape. C'est un conteneur qui surveille les autres conteneurs : quand une nouvelle version de leur image apparaît sur le registre, il la télécharge, arrête l'ancien conteneur et le recrée avec exactement les mêmes options. Le serveur va chercher la mise à jour lui-même, personne n'a besoin de le prévenir.
Le point de départ : une image déjà publiée
La moitié du travail est souvent déjà faite. Si votre dépôt contient un workflow qui construit l'image et la pousse sur GitHub Container Registry (GHCR) à chaque push sur main, il ne manque que le dernier maillon : faire tourner la nouvelle image sur le serveur.
name: Build & Push Docker image
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/utilisateur/mon-site:latestSi ce workflow ne vous parle pas encore, le guide GitHub Actions pour la CI/CD pose les bases : déclencheurs, jobs, secrets.
Pourquoi pas un webhook
Le réflexe, c'est d'ajouter à la fin du workflow un appel HTTP vers le serveur pour lui dire « nouvelle image, redéploie ». Dans mon cas, trois choses bloquaient.
Les webhooks de redéploiement de Portainer sont réservés à l'édition Business. En Community Edition, ils n'existent pas.
Mon Portainer n'est pas exposé sur Internet. Il vit sur un réseau privé, et c'est très bien comme ça : une interface qui pilote Docker n'a rien à faire en accès public. Les runners de GitHub, eux, sont sur Internet. Ils ne peuvent pas le joindre.
Restaient deux contournements, aucun satisfaisant :
| Option | Ce qu'elle demande | Problème |
|---|---|---|
| Webhook Portainer | Édition Business, Portainer joignable depuis GitHub | Payant et exposé |
| Runner GitHub auto-hébergé | Un agent sur le serveur qui exécute docker pull et recrée le conteneur | Un service de plus à maintenir, des droits Docker donnés à des jobs CI |
| VPN dans le workflow (Tailscale, WireGuard) + API Portainer | Une clé d'accès au réseau privé stockée dans GitHub | Beaucoup de pièces mobiles pour un simple redémarrage |
| Watchtower | Un conteneur sur le serveur | Délai de quelques dizaines de secondes |
Le runner auto-hébergé reste un bon outil quand le déploiement fait plus que redémarrer un conteneur : migrations de base, plusieurs services dans un ordre précis. Pour un site dans un conteneur unique, c'est trop.
Inverser le sens : le serveur va chercher
Watchtower travaille en mode pull. Toutes les N secondes, il compare le digest de l'image locale avec celui du registre. S'ils diffèrent, il télécharge, arrête, recrée. Les ports, les variables d'environnement, les volumes, le réseau et la politique de redémarrage sont repris du conteneur d'origine.
Conséquence directe : aucune connexion entrante. Le serveur sort vers ghcr.io, comme il le fait déjà pour un docker pull manuel. Rien à ouvrir dans le pare-feu, rien à exposer.
Un point à connaître avant de foncer. Le projet historique, containrrr/watchtower, a été archivé en décembre 2025. Ses images tournent encore, mais plus personne ne les met à jour. Le fork maintenu est celui de Nicholas Fedor, publié sous nickfedor/watchtower, en version 1.23.1 au 13 octobre 2026. Il garde les mêmes variables d'environnement et le même label, une migration se résume donc à changer le nom de l'image.
Étape 1 : marquer le conteneur à surveiller
Par défaut, Watchtower surveille tous les conteneurs de la machine. Je préfère l'inverse : rien n'est mis à jour sauf ce que je désigne. Une base PostgreSQL qui passe d'une version majeure à l'autre pendant la nuit, c'est le genre de surprise qu'on évite.
La désignation passe par un label. Dans Portainer : Containers, le conteneur du site, Duplicate/Edit. Dans l'onglet Labels, ajouter :
com.centurylinklabs.watchtower.enable = trueVérifier au passage que l'image est bien référencée avec son tag, ici ghcr.io/utilisateur/mon-site:latest, puis Deploy the container et accepter le remplacement.
Si le site est déployé en stack plutôt qu'en conteneur seul, le label s'ajoute dans le fichier Compose :
services:
site:
image: ghcr.io/utilisateur/mon-site:latest
labels:
com.centurylinklabs.watchtower.enable: "true"Étape 2 : lancer Watchtower
Dans Portainer, Stacks, Add stack, nommée watchtower :
services:
watchtower:
image: nickfedor/watchtower:latest
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WATCHTOWER_LABEL_ENABLE: "true"
WATCHTOWER_POLL_INTERVAL: "60"
WATCHTOWER_CLEANUP: "true"
TZ: Europe/ParisTrois variables font tout le travail :
| Variable | Rôle | Défaut |
|---|---|---|
WATCHTOWER_LABEL_ENABLE | Ne surveiller que les conteneurs portant le label à true | false |
WATCHTOWER_POLL_INTERVAL | Délai entre deux vérifications, en secondes | 86400 (24 h) |
WATCHTOWER_CLEANUP | Supprimer l'ancienne image après la mise à jour | false |
Le défaut de 24 heures est pensé pour suivre les mises à jour d'images tierces, pas pour du déploiement continu. À 60 secondes, la vérification d'une seule image coûte une requête légère vers le registre par minute. Sans WATCHTOWER_CLEANUP, chaque déploiement laisse une image orpheline sur le disque, et après une centaine d'articles la partition s'en souvient.
Pour une mise à jour à heure fixe plutôt qu'en continu, WATCHTOWER_SCHEDULE accepte une expression cron à six champs, secondes comprises : 0 0 4 * * * pour 4 h du matin. Les deux options s'excluent.
Étape 3 : tester
Pousser un commit, attendre que le workflow passe au vert dans l'onglet Actions, puis ouvrir les logs du conteneur watchtower dans Portainer. Dans la minute qui suit, on doit voir passer une ligne indiquant la nouvelle image trouvée, puis l'arrêt et la création du conteneur.
docker logs -f watchtowerSi rien ne se passe, trois suspects, dans cet ordre. Le label est absent ou mal orthographié : docker inspect sur le conteneur du site le confirme. L'image du conteneur n'a pas de tag explicite ou pointe vers un digest figé. Ou l'image est privée.
Le cas d'une image privée
Un dépôt GitHub privé ne rend pas l'image privée pour autant : la visibilité d'un package GHCR se règle à part, dans les paramètres du package. Pour savoir où vous en êtes, tentez un pull sans être connecté :
docker logout ghcr.io
docker pull ghcr.io/utilisateur/mon-site:latestS'il passe, l'image est publique et Watchtower n'a besoin de rien. Sinon, il lui faut des identifiants. Le plus simple : se connecter une fois sur le serveur avec un token GitHub limité au scope read:packages, puis monter le fichier généré dans Watchtower.
echo "$GHCR_TOKEN" | docker login ghcr.io -u utilisateur --password-stdin volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /root/.docker/config.json:/config.json:roWatchtower lit /config.json au démarrage et s'authentifie auprès du registre avec.
Être prévenu
Un déploiement silencieux, c'est confortable jusqu'au jour où il échoue en silence. Watchtower envoie des notifications via Shoutrrr, une librairie qui accepte des URL pour une vingtaine de services : Discord, Slack, Telegram, e-mail. J'utilise ntfy pour les notifications push, déjà installé pour les alertes du serveur :
environment:
WATCHTOWER_NOTIFICATION_URL: "ntfy://ntfy.example.com/deploiements"Chaque mise à jour fait vibrer le téléphone, avec le nom du conteneur et l'image installée.
Ce que Watchtower ne fait pas
Il redémarre, il ne vérifie pas. Si la nouvelle image plante au démarrage, Watchtower ne revient pas en arrière. Le conteneur boucle, et le site tombe jusqu'à la correction.
Le remède tient dans le workflow : publier, en plus de :latest, un tag par commit.
tags: |
ghcr.io/utilisateur/mon-site:latest
ghcr.io/utilisateur/mon-site:${{ github.sha }}Le jour où un déploiement casse, on repasse le conteneur sur le tag du commit précédent dans Portainer. Watchtower ne touchera pas à une image taguée par un SHA, puisqu'aucune nouvelle version de ce tag ne sera jamais publiée. Une fois le correctif poussé, on revient sur :latest.
Il y a aussi une coupure. Entre l'arrêt de l'ancien conteneur et le démarrage du nouveau, le site ne répond pas pendant quelques secondes. Pour un blog, c'est acceptable. Pour un service qui ne doit jamais tomber, il faut deux instances derrière un reverse proxy, et on sort du périmètre de l'outil.
Enfin, le socket Docker. Monter /var/run/docker.sock donne à Watchtower un contrôle total sur Docker, donc sur la machine. C'est la raison de prendre un projet maintenu plutôt que l'image archivée, et de ne jamais exposer son API HTTP sur Internet. Ses propres auteurs le présentent comme un outil pour homelab et environnements personnels, pas pour la production d'une entreprise, où un orchestrateur avec un vrai pipeline de déploiement prend le relais.
Le déploiement continu n'a pas besoin d'un serveur joignable depuis Internet. Il a besoin que quelqu'un vérifie régulièrement s'il y a du nouveau, et ce quelqu'un peut très bien être le serveur lui-même. Avec un label, trois variables et un tag par commit pour se rattraper, un git push suffit désormais à mettre ce blog à jour.