Compose
An entire application described in a single file.
Docker Compose
A real-world application is almost never a single container. It’s an API, a database, a cache, sometimes an asynchronous worker, and a proxy in front. Five containers means five docker run commands — with the right ports, the right variables, the right network, in the right order, and remembering all of it tomorrow morning.
That works for a while, with shell scripts copied from one project to the next. Then it always degrades the same way: your colleague’s script uses a different Postgres version, nobody remembers why the worker has some obscure option, onboarding a new developer costs half a day, and the database password sits in the history of three terminals. The problem isn’t the number of commands. It’s that there is no single, verifiable description of what “the application” means.
Docker Compose addresses exactly that gap. A YAML file declares all the services, their images, their variables, their volumes, and their dependencies. That file lives in the Git repository, next to the code. Cloning the project and typing a single command is enough to get the complete stack, identical for everyone — and git log explains why a given option was added.
A word on syntax before we begin, because it’s a source of confusion. The modern form is docker compose, in two words: it’s a plugin built into the Docker client. The older form docker-compose, with a hyphen, is a separate Python binary, “v1”, whose development stopped in 2023. The two look alike, but v1 ignores part of the current syntax. Everything below uses docker compose.
A First File, Service by Service
The file is named compose.yaml and sits at the project root. That’s the name recommended by the Compose specification; docker-compose.yml is still recognized for compatibility, and you’ll often run into it in existing projects.
# 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:
Two details in this file are worth noting right away.
The database publishes no ports: there is no ports key under db. It is still perfectly reachable by the API at db:5432, because both services share an internal network that Compose creates automatically. Publishing 5432:5432 would expose your database on every interface of the machine, which is only necessary if you want to connect a graphical client from the host.
And services refer to each other by name, never by IP address. Container IPs change every time a container is recreated; names don’t. The next lesson covers this networking mechanism in detail.
The Daily Command Cycle
Compose works by convergence: you describe the desired state, and it computes the actions needed to reach it. up doesn’t restart everything; it only touches what has changed.
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
When it comes to stopping, you need to carefully distinguish three commands, because one of them destroys data:
# 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
Finally, the most worthwhile diagnostic command:
# 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
A word about the project name. Compose prefixes every object it creates with a name derived from the current directory: the db-data volume becomes monprojet_db-data. This is what lets you run two independent stacks on the same machine, and it’s also why renaming a directory can seem to “lose” your data — it’s still there, under the old prefix.
docker compose -p mon-projet up -d
# ou, de facon persistante, dans .env : COMPOSE_PROJECT_NAME=mon-projet
image or build: Consuming or Producing
image alone pulls an existing image from a registry: that’s the case for Postgres, Redis, or Nginx, which you never build yourself. build builds from a Dockerfile: that’s the case for your own code. Combining both keys gives the built image a name, which makes it pushable to a registry.
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
Here’s the beginner’s trap: up does not rebuild an existing image. You edit your Dockerfile, rerun up -d, and nothing changes. Compose reuses the image that was already built; you have to ask for a rebuild explicitly.
docker compose build api # reconstruire un service
docker compose build --no-cache api # sans cache
docker compose up -d --build # reconstruire puis demarrer
Dependencies: depends_on Alone Isn’t Enough
This is the most common and most confusing source of errors, because the symptom is intermittent.
The short form of depends_on only guarantees a startup order. Compose waits for the db container to be started, not for Postgres to accept connections. But Postgres takes two to ten seconds to initialize its cluster on first startup. Your API starts during that time, fails with connection refused, and exits. On a fast machine it sometimes works, hence the impression of a random bug.
# Insuffisant : garantit l'ORDRE, pas la DISPONIBILITE
services:
api:
depends_on:
- db
The correct solution combines a healthcheck on the service being depended on with a condition on the dependent service:
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
The third condition, service_completed_successfully, neatly solves the schema migration problem: the migrate service runs once, exits with code 0, and only then does the API start. No more homemade wait scripts.
# 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)
One honest caveat: depends_on only comes into play at startup. If the database restarts during the application’s lifetime, your application must know how to reconnect. Reconnection logic with exponential backoff in your code remains essential; depends_on eliminates startup noise, but it doesn’t replace robustness.
Environment Variables: Three Distinct Mechanisms
The confusion comes from the fact that three different things have nearly the same name.
The .env file next to compose.yaml feeds interpolation in the YAML itself: its values replace the ${...} expressions before Compose processes anything else. The environment key defines the variables received by the process inside the container. The env_file key loads a variables file directly into the container, without going through 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}
The ${VAR:?message} form is the best safeguard available: it turns a forgotten secret into an immediate, explicit error, rather than an application that starts with an empty string and fails authentication three hours later.
Two security rules to finish. A .env containing real secrets never goes into Git: commit a documented .env.example with no values. And in production, secrets don’t go through variables that are readable in docker inspect: use the secrets section, which mounts them as files under /run/secrets/, so the application reads a path rather than a value.
Profiles: Optional Services in the Same Stack
A development stack ends up accumulating tools that nobody needs all the time: a database admin interface, a fake SMTP server, a load injector. Starting them systematically wastes memory; putting them in a second file duplicates configuration. A service assigned to a profile only starts if that profile is requested.
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
Layering Multiple Files
The same project often has to run in different contexts: mounted code and hot reload in development, a pinned image and resource limits in production. Compose layers files rather than duplicating them. A file named compose.override.yaml is loaded automatically in addition to 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"
# Development: compose.yaml + compose.override.yaml, automatically
docker compose up -d
# Production: files are named explicitly, the override is NOT loaded
docker compose -f compose.yaml -f compose.prod.yaml up -d
# Always check the merge result before deploying
docker compose -f compose.yaml -f compose.prod.yaml config
You need to know the merge rule: for simple values, the last file wins; for lists such as ports or volumes, the entries are accumulated. This is a common surprise, and docker compose config reveals it immediately.
What Compose doesn’t do
Compose orchestrates containers on a single host. It doesn’t distribute anything across multiple machines, doesn’t restart a service elsewhere when a server goes down, and doesn’t provide any zero-downtime deployment. That is the domain of Kubernetes or Docker Swarm.
This doesn’t rule it out for production: a single server managed with Compose is a perfectly defensible architecture for a service with moderate traffic, and much simpler to operate than a cluster. Lesson 6 covers exactly this case.
Local scaling does exist, however, and is useful for testing a worker:
# Three instances of the worker service
docker compose up -d --scale worker=3
It is incompatible with a fixed port mapping: three containers can’t bind to port 3000 on the host. Remove the ports key from the service in question, or let Docker pick free ports.
Key takeaways
- A versioned
compose.yamlreplaces a collection of fragiledocker runcommands: the application definition becomes a single document, reviewed by the team and reproducible for everyone. - Use
docker composeas two words.docker-composewith a hyphen is v1, which is discontinued and ignores part of the current syntax. Theversion:key at the top of the file is obsolete and has no effect. - Services reach each other by name thanks to internal DNS. Only publish with
portswhat must be reachable from the outside: a database generally isn’t one of those things. depends_onalone only guarantees a startup order. To wait for actual availability, add ahealthcheckandcondition: service_healthy;service_completed_successfullycleanly handles the case of migrations.docker compose updoesn’t rebuild your images: remember--buildafter any change to a Dockerfile. Anddocker compose down -vdeletes volumes, and therefore data.${VAR:?message}turns a missing secret into an immediate error. If you’re unsure about a variable,docker compose configdisplays the file as actually interpreted.- Profiles isolate optional tools, and override files adapt the same stack to development or production without duplicating the configuration.
Tip: get into the habit of running
docker compose configbefore anyupon a modified configuration. Most of the hours lost on Compose come down to the gap between what you think you wrote and what interpolation actually produced.
Comments
Comments are powered by GitHub Discussions
Sign in with GitHub to join the discussion