GitOps est presque toujours présenté avec Kubernetes en toile de fond : ArgoCD, Flux, des manifests par centaines. Résultat, beaucoup d’équipes qui déploient sur un simple VPS avec Docker Compose pensent que le modèle ne les concerne pas. Elles continuent à déployer en SSH : git pull, docker compose up -d, en croisant les doigts pour que personne n’oublie une étape.
C’est dommage, parce que le cœur de GitOps n’a rien à voir avec Kubernetes. Et depuis quelque temps, un outil comble exactement ce vide : doco-cd, un moteur de déploiement continu pour Docker Compose. Combiné à SOPS et age pour chiffrer les secrets directement dans le repo, on obtient du GitOps sans Kubernetes : un pipeline complet où déployer se résume à git push. Cet article explique le principe, puis déroule la mise en place sur un projet Django avec plusieurs fichiers compose.
GitOps, le principe sans le jargon
GitOps tient en trois idées :
- Git est la source de vérité. L’état désiré de l’infrastructure (quels services, quelles images, quelle configuration) est décrit dans un repo. Pas dans la tête de l’ops, pas dans un runbook Notion.
- Le déploiement suit un modèle pull. Un agent tourne sur le serveur cible et surveille le repo. Quand l’état déclaré change, l’agent applique la différence. Personne ne pousse de commandes vers le serveur.
- Toute modification passe par un commit. Déployer, c’est merger. Revenir en arrière, c’est
git revert. L’historique de déploiement est l’historique Git, avec auteur, date et diff.
Le contraste avec le déploiement push classique (une CI qui se connecte en SSH et exécute des commandes) est réel. En mode push, la CI détient des credentials SSH vers la production, et l’état du serveur diverge silencieusement dès que quelqu’un exécute une commande à la main. En mode pull, le serveur ne s’ouvre à personne : c’est lui qui vient chercher l’information, et il réapplique l’état déclaré à chaque changement.
Rien dans ces trois idées n’exige Kubernetes. Il faut juste un agent capable de lire un repo et de réconcilier l’état d’un hôte Docker. C’est exactement ce que fait doco-cd.
doco-cd : le modèle ArgoCD appliqué à Docker Compose
doco-cd (Docker Compose Continuous Deployment) est un agent GitOps écrit en Go, distribué en image distroless minimale, avec une empreinte mémoire et CPU réduite. Il se présente lui-même comme une alternative simple à Portainer ou ArgoCD pour Docker. Son fonctionnement :
- Il surveille un ou plusieurs repos Git, soit par webhook (le provider Git le notifie à chaque push), soit par polling (il vérifie le repo à intervalle régulier).
- À chaque changement détecté, il clone le repo, lit un fichier de configuration
.doco-cd.yamlà la racine, et exécute l’équivalent d’undocker compose up -davec les fichiers compose déclarés. - Il parle directement au socket Docker de l’hôte. Pas de daemon supplémentaire, pas de base de données.
Deux points le distinguent des scripts maison :
Le déchiffrement SOPS est natif. doco-cd inspecte les fichiers du projet au moment du déploiement (fichiers compose, fichiers .env, configs, secrets, volumes montés) et déchiffre à la volée tout fichier chiffré avec SOPS. La détection se fait sur le contenu du fichier, aucune convention de nommage n’est requise.
La configuration de déploiement vit dans le repo applicatif. Le fichier .doco-cd.yaml versionné à côté du code décrit quoi déployer et comment. L’agent sur le serveur n’a presque aucune configuration propre.
Les secrets, le vrai problème de GitOps
Si Git est la source de vérité, les secrets doivent y être aussi. Sinon on retombe dans le problème initial : un fichier .env déposé à la main sur le serveur, hors de tout versionnement, que personne ne sait reconstituer le jour où la machine meurt.
Mais committer POSTGRES_PASSWORD=hunter2 en clair est évidemment exclu. La réponse classique de l’écosystème s’appelle SOPS (Secrets OPerationS), un outil qui chiffre les valeurs d’un fichier structuré (YAML, JSON, dotenv, INI) en laissant les clés lisibles. Le fichier chiffré reste diffable : on voit dans une PR que POSTGRES_PASSWORD a changé, sans jamais voir sa valeur.
SOPS délègue le chiffrement à un backend. Historiquement PGP, aujourd’hui la recommandation est age : un outil de chiffrement moderne, sans serveurs de clés ni web of trust, avec des clés courtes et un format minimal. Une paire de clés age tient sur deux lignes de texte.
Le flux complet devient :
- Les développeurs chiffrent les secrets avec la clé publique age. Elle peut être committée, tout le monde peut chiffrer.
- Seul le serveur détient la clé privée. doco-cd s’en sert pour déchiffrer au moment du déploiement.
- Le repo contient tout, mais ne révèle rien.
Le projet d’exemple : Django, Celery et trois fichiers compose
Prenons un projet Django réaliste : une API avec sa base PostgreSQL, un worker Celery avec Redis, et un fichier compose séparé pour la partie worker afin de pouvoir la faire évoluer indépendamment. C’est la topologie typique dès qu’on traite des tâches asynchrones, par exemple le relay du pattern Transactional Outbox. L’arborescence :
django-shop/
├── .doco-cd.yaml # config de déploiement doco-cd
├── .sops.yaml # règles de chiffrement SOPS
├── src/ # le code Django
│ ├── manage.py
│ └── config/
└── deploy/
├── compose.yaml # web + postgres
├── compose.worker.yaml # celery + redis
└── .env # secrets, chiffré avec SOPS
Le fichier deploy/compose.yaml :
services:
web:
image: ghcr.io/monorg/django-shop:${IMAGE_TAG:-latest}
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000
env_file: .env
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
retries: 10
restart: unless-stopped
volumes:
pgdata:
Et deploy/compose.worker.yaml, fusionné avec le premier au déploiement :
services:
worker:
image: ghcr.io/monorg/django-shop:${IMAGE_TAG:-latest}
command: celery -A config worker --loglevel info
env_file: .env
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:7-alpine
restart: unless-stopped
Notez le ${IMAGE_TAG:-latest} : l’image applicative est construite par la CI (en dehors du périmètre de cet article) et poussée sur un registre. Déployer une nouvelle version consiste à changer IMAGE_TAG dans le .env et à pousser le commit. Le repo décrit l’état voulu, doco-cd l’applique.
Étape 1 : générer la paire de clés age
Sur votre poste :
age-keygen -o age.key
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
Le fichier age.key contient la clé privée (une ligne commençant par AGE-SECRET-KEY-). Il ne rejoint jamais le repo : il ira sur le serveur, et dans un gestionnaire de mots de passe pour la sauvegarde. La clé publique affichée en commentaire, elle, peut circuler librement.
Étape 2 : configurer SOPS et chiffrer le .env
Le fichier .sops.yaml à la racine du repo déclare quelles règles de chiffrement s’appliquent à quels chemins :
creation_rules:
- path_regex: deploy/.*\.env$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
On écrit ensuite le deploy/.env en clair, localement :
DJANGO_SECRET_KEY=django-insecure-change-me
DJANGO_ALLOWED_HOSTS=shop.example.com
POSTGRES_DB=shop
POSTGRES_USER=shop
POSTGRES_PASSWORD=un-vrai-mot-de-passe
CELERY_BROKER_URL=redis://redis:6379/0
IMAGE_TAG=1.4.2
Puis on le chiffre sur place :
sops encrypt --in-place deploy/.env
Le fichier reste un dotenv valide structurellement, mais chaque valeur est remplacée par un bloc ENC[AES256_GCM,...], et SOPS ajoute ses métadonnées (dont la clé publique age utilisée) :
POSTGRES_PASSWORD=ENC[AES256_GCM,data:8Zw1p9X...,iv:...,tag:...,type:str]
C’est ce fichier chiffré qui est committé. Pour le modifier plus tard, sops edit deploy/.env l’ouvre déchiffré dans l’éditeur et le rechiffre à la sauvegarde. Le diff Git montre quelles clés ont changé, jamais les valeurs.
Étape 3 : déclarer le déploiement dans .doco-cd.yaml
À la racine du repo :
name: django-shop
working_dir: deploy
compose_files:
- compose.yaml
- compose.worker.yaml
Trois lignes de configuration utile : le nom du projet compose, le répertoire de travail, et la liste des fichiers compose à fusionner. C’est l’équivalent du -f compose.yaml -f compose.worker.yaml qu’on taperait à la main. Un troisième fichier compose (monitoring, exporteurs, tâches cron) s’ajoute d’une ligne.
Étape 4 : installer doco-cd sur le serveur
Le seul composant installé manuellement sur le serveur, une fois pour toutes. Un fichier compose dédié :
services:
doco-cd:
image: ghcr.io/kimdre/doco-cd:latest
restart: unless-stopped
environment:
TZ: Europe/Paris
GIT_ACCESS_TOKEN: ${GIT_ACCESS_TOKEN} # token en lecture sur le repo
SOPS_AGE_KEY_FILE: /run/secrets/age_key
POLL_CONFIG: |
- url: https://github.com/monorg/django-shop.git
reference: main
interval: 180
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- data:/data
secrets:
- age_key
healthcheck:
test: ["CMD", "/doco-cd", "healthcheck"]
interval: 30s
secrets:
age_key:
file: ./age.key
volumes:
data:
Deux choix méritent une explication.
La clé age passe par un secret compose, monté dans le conteneur et référencé via SOPS_AGE_KEY_FILE, plutôt que par la variable SOPS_AGE_KEY en clair dans l’environnement. Un docker inspect sur le conteneur n’exposera pas la clé privée.
Le polling plutôt que le webhook, ici toutes les 180 secondes sur la branche main. doco-cd supporte aussi les webhooks (variable WEBHOOK_SECRET, endpoint HTTP à exposer au provider Git), ce qui donne des déploiements instantanés. Mais le webhook impose d’exposer doco-cd sur Internet derrière un reverse proxy. Pour un serveur qui n’a pas vocation à recevoir du trafic entrant du provider Git, le polling est plus simple et suffit largement : une latence de trois minutes sur un déploiement est rarement un problème.
On démarre :
docker compose up -d
À partir de là, chaque push sur main qui modifie le projet déclenche un redéploiement. doco-cd clone le repo, détecte que deploy/.env est chiffré (le contenu contient les marqueurs SOPS), le déchiffre avec la clé age, interpole les variables, fusionne les deux fichiers compose et applique l’état.
Les migrations Django
Un docker compose up ne lance pas les migrations. Le plus simple dans ce modèle est de les intégrer au démarrage du conteneur web, via un entrypoint :
#!/bin/sh
set -e
python manage.py migrate --noinput
exec "$@"
C’est le compromis habituel des déploiements single-host : acceptable tant qu’un seul conteneur web démarre à la fois. Si vous scalez le service web à plusieurs répliques, sortez les migrations dans un service one-shot dédié pour éviter les exécutions concurrentes.
Ce que ça change au quotidien
La bascule paraît modeste, elle ne l’est pas.
Déployer devient un non-événement. Changer IMAGE_TAG=1.4.3, ouvrir une PR, merger. Pas de fenêtre de tir, pas de “qui a la main sur le serveur ?”.
Le rollback est un revert. L’état précédent est un commit Git. git revert et trois minutes plus tard le serveur fait tourner l’ancienne version. Aucune procédure spécifique à documenter.
L’audit est gratuit. Qui a changé la config Redis en production, quand, et pourquoi ? git log deploy/. La question “qu’est-ce qui tourne en prod ?” a une réponse exacte : ce que décrit main.
Le serveur devient remplaçable. Provisionner un nouveau serveur se résume à installer Docker, déposer la clé age et démarrer doco-cd. Tout le reste découle du repo.
Les limites à connaître
doco-cd gère un hôte Docker (ou un cluster Swarm), pas une flotte. Pas de scheduling multi-nœuds, pas d’autoscaling, pas de déploiement progressif natif : si vous avez besoin de ça, c’est le signe que Kubernetes et ArgoCD deviennent pertinents. L’intérêt de doco-cd est précisément de ne pas payer ce coût tant qu’on n’en a pas besoin.
La clé age privée sur le serveur est le point sensible du dispositif. Quiconque la détient peut déchiffrer tous les secrets du repo, y compris dans l’historique Git. Deux conséquences pratiques : restreindre l’accès au serveur comme on restreindrait l’accès à un vault, et accepter qu’une rotation de clé n’efface pas le passé (un secret compromis se change, il ne se “déchiffre” pas rétroactivement).
Enfin, SOPS chiffre des fichiers, pas des accès. Il n’y a ni contrôle d’accès par secret, ni journal de consultation, ni génération dynamique de credentials. Pour une équipe de trois personnes sur un VPS, c’est exactement le niveau de complexité adapté. Pour des besoins plus fins, doco-cd sait aussi interroger des gestionnaires de secrets externes (OpenBao, AWS Secrets Manager, Bitwarden, 1Password), ce qui offre une trajectoire d’évolution sans changer d’outil de déploiement.
Conclusion
GitOps n’est pas une technologie, c’est un contrat : Git décrit l’état voulu, un agent l’applique, toute modification passe par un commit. Kubernetes a popularisé le modèle, mais un VPS avec Docker Compose y a droit aussi. doco-cd fournit l’agent, SOPS et age règlent la question des secrets, et le résultat tient en quelques fichiers versionnés : deux compose, un .env chiffré, un .doco-cd.yaml, un .sops.yaml. Le jour où le projet dépasse ce cadre, les réflexes acquis (état déclaré, modèle pull, secrets chiffrés dans le repo) se transposent tels quels vers Flux ou ArgoCD. En attendant, déployer redevient ce que ça aurait toujours dû être : un git push.
