Compose
Une application entière décrite dans un seul fichier.
Docker Compose
Une application réelle n’est presque jamais un conteneur. C’est une API, une base de données, un cache, parfois un worker asynchrone et un proxy en frontal. Cinq conteneurs, donc cinq docker run — avec les bons ports, les bonnes variables, le bon réseau, dans le bon ordre, et en se souvenant de tout cela demain matin.
Ça tient un temps, à coups de scripts shell recopiés d’un projet à l’autre. Puis ça se dégrade toujours de la même manière : le script du collègue n’a pas la même version de Postgres, personne ne sait plus pourquoi le worker porte telle option obscure, l’arrivée d’un nouveau développeur coûte une demi-journée, et le mot de passe de la base traîne dans l’historique de trois terminaux. Le problème n’est pas le nombre de commandes. C’est qu’il n’existe aucune description unique et vérifiable de ce que « l’application » signifie.
Docker Compose répond exactement à ce manque. Un fichier YAML déclare l’ensemble des services, leurs images, leurs variables, leurs volumes, leurs dépendances. Ce fichier vit dans le dépôt Git, à côté du code. Cloner le projet et taper une seule commande suffit à obtenir la pile complète, identique pour tout le monde — et git log explique pourquoi telle option a été ajoutée.
Un mot sur la syntaxe avant de commencer, parce qu’elle est source de confusion. La forme moderne est docker compose, en deux mots : c’est un plugin intégré au client Docker. L’ancienne forme docker-compose, avec un tiret, est un binaire Python séparé, la « v1 », dont le développement est arrêté depuis 2023. Les deux se ressemblent, mais la v1 ignore une partie de la syntaxe actuelle. Tout ce qui suit utilise docker compose.
Un premier fichier, service par service
Le fichier s’appelle compose.yaml à la racine du projet. C’est le nom recommandé par la spécification Compose ; docker-compose.yml reste reconnu pour compatibilité, et vous le rencontrerez souvent dans des projets existants.
# compose.yaml
# Pas de cle "version:" en tete : elle est obsolete et sans effet.
services:
# ---------- L'application ----------
api:
build:
context: .
dockerfile: Dockerfile
image: mon-api:dev # nom attribue a l'image construite
ports:
- "3000:3000" # hote:conteneur
environment:
NODE_ENV: development
# "db" et "cache" sont des noms de SERVICES : ils sont resolus
# en DNS sur le reseau de la pile. Aucune adresse IP a ecrire.
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/appdb
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
restart: unless-stopped
# ---------- La base de donnees ----------
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
# Volume nomme : les donnees survivent a la suppression du conteneur
- db-data:/var/lib/postgresql/data
healthcheck:
# Le double $$ echappe l'interpolation Compose : la variable doit
# etre resolue DANS le conteneur, pas par Compose.
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
# ---------- Le cache ----------
cache:
image: redis:7-alpine
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
volumes:
- cache-data:/data
restart: unless-stopped
volumes:
db-data:
cache-data:
Deux détails de ce fichier méritent d’être relevés tout de suite.
La base de données ne publie aucun port : aucune clé ports sous db. Elle reste pourtant parfaitement joignable par l’API à l’adresse db:5432, parce que les deux services partagent un réseau interne créé automatiquement par Compose. Publier 5432:5432 exposerait votre base sur toutes les interfaces de la machine, ce qui n’est nécessaire que pour y brancher un client graphique depuis l’hôte.
Et les services se désignent par leur nom, jamais par une adresse IP. Les IP des conteneurs changent à chaque recréation ; les noms, non. La leçon suivante détaille cette mécanique réseau.
Le cycle de commandes quotidien
Compose fonctionne par convergence : vous décrivez l’état voulu, il calcule les actions à mener. up ne relance pas tout, il ne touche que ce qui a changé.
docker compose up -d # demarrer en arriere-plan
docker compose up -d --build # reconstruire les images locales avant
docker compose up -d --wait # attendre que les healthchecks soient verts
docker compose ps # etat de la pile, avec la sante
docker compose logs -f # journaux agreges, en direct
docker compose logs -f --tail 100 api # un seul service
# Ouvrir un shell dans un service qui TOURNE
docker compose exec api sh
docker compose exec db psql -U app -d appdb
# Lancer une commande dans un conteneur JETABLE.
# run cree un nouveau conteneur, exec entre dans celui qui tourne.
docker compose run --rm api npm run migrate
docker compose restart api
L’arrêt mérite qu’on distingue soigneusement trois commandes, parce que l’une détruit des données :
# Arreter sans rien supprimer. Reversible par "start".
docker compose stop
# Arreter ET supprimer conteneurs et reseaux.
# Les VOLUMES sont conserves : vos donnees survivent.
docker compose down
# Idem, plus la suppression des volumes. Vos donnees sont PERDUES.
docker compose down -v
# Supprimer aussi les conteneurs orphelins d'une ancienne version du fichier
docker compose down --remove-orphans
Enfin, la commande de diagnostic la plus rentable :
# Affiche le fichier tel que Compose le comprend REELLEMENT : variables
# interpolees, fichiers fusionnes, valeurs par defaut appliquees.
# Le premier reflexe quand une variable "ne passe pas".
docker compose config
Un mot sur le nom de projet. Compose préfixe tous les objets qu’il crée par un nom dérivé du dossier courant : le volume db-data devient monprojet_db-data. C’est ce qui permet de faire tourner deux piles indépendantes sur une même machine, et c’est aussi ce qui explique qu’un dossier renommé fasse apparemment « perdre » les données — elles sont toujours là, sous l’ancien préfixe.
docker compose -p mon-projet up -d
# ou, de facon persistante, dans .env : COMPOSE_PROJECT_NAME=mon-projet
image ou build : consommer ou produire
image seul tire une image existante d’un registre : c’est le cas de Postgres, Redis ou Nginx, que vous ne construisez jamais. build construit depuis un Dockerfile : c’est le cas de votre code. Combiner les deux clés donne un nom à l’image construite, ce qui la rend poussable dans un registre.
services:
api:
build:
context: . # comme le "." de docker build
dockerfile: docker/Dockerfile.api # relatif au contexte
target: runtime # etape d'un Dockerfile multi-stage
args: # equivaut a --build-arg
NODE_VERSION: "22"
image: registre.exemple.com/equipe/mon-api:dev
Le piège de débutant est ici : up ne reconstruit pas une image existante. Vous modifiez votre Dockerfile, vous relancez up -d, rien ne change. Compose réutilise l’image déjà construite ; il faut le demander explicitement.
docker compose build api # reconstruire un service
docker compose build --no-cache api # sans cache
docker compose up -d --build # reconstruire puis demarrer
Les dépendances : depends_on seul ne suffit pas
C’est la source d’erreur la plus fréquente et la plus déroutante, parce que le symptôme est intermittent.
La forme courte de depends_on garantit uniquement un ordre de démarrage. Compose attend que le conteneur db soit lancé, pas que Postgres accepte des connexions. Or Postgres met deux à dix secondes à initialiser son cluster au premier démarrage. Votre API démarre pendant ce temps, échoue sur connection refused, et sort. Sur une machine rapide ça passe parfois : d’où l’impression d’un bug aléatoire.
# Insuffisant : garantit l'ORDRE, pas la DISPONIBILITE
services:
api:
depends_on:
- db
La solution correcte associe un healthcheck sur le service dont on dépend et une condition sur le service dépendant :
services:
api:
depends_on:
db:
condition: service_healthy # attend le healthcheck au vert
cache:
condition: service_started # le demarrage suffit
migrate:
condition: service_completed_successfully # attend une sortie en code 0
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s # frequence des verifications
timeout: 3s # au-dela, la verification est un echec
retries: 10 # echecs consecutifs avant le statut "unhealthy"
start_period: 10s # periode de grace : les echecs n'y sont pas comptes
migrate:
build: .
command: ["npm", "run", "migrate"]
depends_on:
db:
condition: service_healthy
restart: "no" # un travail ponctuel, pas un service
La troisième condition, service_completed_successfully, résout élégamment le problème des migrations de schéma : le service migrate s’exécute une fois, sort en code 0, et l’API ne démarre qu’ensuite. Plus besoin de scripts d’attente artisanaux.
# La colonne STATUS affiche (healthy), (unhealthy) ou (health: starting)
docker compose ps
# Historique detaille des verifications, avec les sorties de commande
docker inspect --format '{{json .State.Health}}' $(docker compose ps -q db)
Une réserve honnête : depends_on n’intervient qu’au démarrage. Si la base redémarre en cours de vie, votre application doit savoir se reconnecter. Une logique de reconnexion avec temporisation progressive dans le code reste indispensable ; depends_on supprime le bruit au démarrage, il ne remplace pas la robustesse.
Variables d’environnement : trois mécanismes distincts
La confusion vient de ce que trois choses différentes portent presque le même nom.
Le fichier .env à côté du compose.yaml alimente l’interpolation dans le YAML lui-même : ses valeurs remplacent les ${...} avant que Compose ne traite quoi que ce soit. La clé environment définit les variables reçues par le processus dans le conteneur. La clé env_file charge un fichier de variables directement dans le conteneur, sans passer par l’interpolation.
# .env -- a exclure de Git via .gitignore
DB_PASSWORD=motdepasse-de-dev
API_PORT=3000
TAG=dev
services:
api:
image: mon-api:${TAG} # interpole AVANT le demarrage
ports:
- "${API_PORT}:3000"
env_file:
- ./config/api.env # injecte tel quel dans le conteneur
environment:
LOG_LEVEL: ${LOG_LEVEL:-info} # valeur par defaut si absente
# Deux-points + point d'interrogation : Compose ECHOUE si la
# variable est absente ou vide. A utiliser pour tout secret.
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET est obligatoire}
La forme ${VAR:?message} est le meilleur garde-fou disponible : elle transforme un secret oublié en erreur immédiate et explicite, plutôt qu’en une application qui démarre avec une chaîne vide et échoue à l’authentification trois heures plus tard.
Deux règles de sécurité pour finir. Un .env contenant de vrais secrets n’entre jamais dans Git : versionnez un .env.example documenté et sans valeurs. Et en production, les secrets ne passent pas par des variables lisibles dans docker inspect : on utilise la section secrets, qui les monte comme fichiers sous /run/secrets/, l’application lisant alors un chemin plutôt qu’une valeur.
Profils : des services optionnels dans la même pile
Une pile de développement finit par accumuler des outils dont personne n’a besoin en permanence : une interface d’administration de base, un faux serveur SMTP, un injecteur de charge. Les démarrer systématiquement gaspille de la mémoire ; les mettre dans un second fichier duplique la configuration. Un service affecté à un profil ne démarre que si ce profil est demandé.
services:
api:
build: .
ports:
- "3000:3000"
# Aucun profil : ce service demarre toujours
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
adminer: # administration de la base
image: adminer:4
profiles: ["debug"]
ports:
- "8080:8080"
mailpit: # faux serveur SMTP, dans DEUX profils
image: axllent/mailpit:latest
profiles: ["debug", "mail"]
ports:
- "8025:8025"
k6: # tests de charge, jamais par accident
image: grafana/k6:latest
profiles: ["perf"]
command: ["run", "/scripts/charge.js"]
volumes:
- ./perf:/scripts:ro # :ro monte en lecture seule
docker compose up -d # api + db seulement
docker compose --profile debug up -d # + adminer + mailpit
docker compose --profile debug --profile perf up -d # profils cumules
COMPOSE_PROFILES=debug docker compose up -d # via l'environnement
docker compose --profile debug config --services # lister sans demarrer
Superposer plusieurs fichiers
Le même projet doit souvent tourner dans des contextes différents : code monté et rechargement à chaud en développement, image figée et limites de ressources en production. Compose superpose les fichiers plutôt que de les dupliquer. Un fichier nommé compose.override.yaml est chargé automatiquement en plus de compose.yaml.
# compose.yaml -- la base commune, valable partout
services:
api:
image: registre.exemple.com/equipe/mon-api:${TAG:-latest}
environment:
NODE_ENV: production
restart: unless-stopped
# compose.override.yaml -- charge automatiquement, pour le developpement
services:
api:
build: . # on construit localement
environment:
NODE_ENV: development
DEBUG: "app:*"
volumes:
# Le code de l'hote remplace celui de l'image : une modification
# est visible sans reconstruire.
- ./src:/app/src
# Volume anonyme : protege le node_modules de l'image de
# l'ecrasement par celui de l'hote.
- /app/node_modules
command: ["npm", "run", "dev"]
ports:
- "9229:9229" # port du debogueur Node
# compose.prod.yaml -- charge explicitement
services:
api:
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Developpement : compose.yaml + compose.override.yaml, automatiquement
docker compose up -d
# Production : on nomme les fichiers, l'override n'est PAS charge
docker compose -f compose.yaml -f compose.prod.yaml up -d
# Toujours verifier le resultat de la fusion avant de deployer
docker compose -f compose.yaml -f compose.prod.yaml config
La règle de fusion est à connaître : pour les valeurs simples, le dernier fichier gagne ; pour les listes comme ports ou volumes, les entrées sont cumulées. Une surprise fréquente, que docker compose config révèle immédiatement.
Ce que Compose ne fait pas
Compose orchestre des conteneurs sur un seul hôte. Il ne répartit rien sur plusieurs machines, ne redémarre pas un service ailleurs quand un serveur tombe, n’assure aucun déploiement sans coupure. C’est le domaine de Kubernetes ou de Docker Swarm.
Cela ne le disqualifie pas pour la production : un serveur unique piloté par Compose est une architecture parfaitement défendable pour un service à trafic modéré, et beaucoup plus simple à opérer qu’un cluster. La leçon 6 traite précisément ce cas.
La mise à l’échelle locale existe cependant, et rend service pour tester un worker :
# Trois instances du service worker
docker compose up -d --scale worker=3
Elle est incompatible avec une publication de port fixe : trois conteneurs ne peuvent pas se lier au port 3000 de l’hôte. Retirez la clé ports du service concerné, ou laissez Docker choisir des ports libres.
À retenir
- Un
compose.yamlversionné remplace une collection dedocker runfragiles : la définition de l’application devient un document unique, relu en revue et reproductible pour toute l’équipe. - Utilisez
docker composeen deux mots.docker-composeavec un tiret est la v1, abandonnée, qui ignore une partie de la syntaxe actuelle. La cléversion:en tête de fichier est obsolète et sans effet. - Les services se joignent par leur nom grâce au DNS interne. Ne publiez avec
portsque ce qui doit être atteignable depuis l’extérieur : une base de données n’en fait généralement pas partie. depends_onseul ne garantit qu’un ordre de démarrage. Pour attendre une réelle disponibilité, ajoutez unhealthchecketcondition: service_healthy;service_completed_successfullyrègle proprement le cas des migrations.docker compose upne reconstruit pas vos images : pensez à--buildaprès toute modification d’un Dockerfile. Etdocker compose down -vsupprime les volumes, donc les données.${VAR:?message}transforme un secret manquant en erreur immédiate. En cas de doute sur une variable,docker compose configaffiche le fichier réellement interprété.- Les profils isolent les outils optionnels et les fichiers de surcharge adaptent la même pile au développement ou à la production, sans dupliquer la configuration.
Conseil : prenez l’habitude de lancer
docker compose configavant toutupsur une configuration modifiée. La plupart des heures perdues sur Compose se jouent entre ce que vous croyez avoir écrit et ce que l’interpolation a réellement produit.
Commentaires
Les commentaires sont alimentés par GitHub Discussions
Connectez-vous avec GitHub pour participer à la discussion