Docker Compose en local vs production : éliminer la dérive d’environnement et les mauvaises surprises
Docker Compose est souvent adopté comme “outil de dev”, puis, par pragmatisme, on l’emmène en production. C’est là que surgissent les surprises : variables d’environnement différentes, volumes qui masquent des fichiers, images pas vraiment identiques, dépendances “qui marchent sur ma machine”, configuration réseau divergente, montages bind qui n’existent pas sur le serveur, etc.
Ce tutoriel explique comment réduire la dérive d’environnement (environment drift) entre local et production, sans perdre la rapidité du workflow local. On va construire une approche robuste basée sur :
- une image Docker unique (ou un ensemble d’images) identique entre local et prod ;
- des fichiers Compose superposables (
compose.yml+ overrides) ; - une gestion stricte des variables, secrets, volumes, healthchecks et profils ;
- des commandes reproductibles pour build, test, déploiement et diagnostic.
1) Comprendre la dérive d’environnement (et pourquoi Compose y contribue)
La dérive d’environnement apparaît quand le comportement effectif (code + dépendances + config + infra) diffère entre environnements. Avec Compose, les causes classiques sont :
- Bind mounts en local (
./src:/app) mais pas en prod → en prod, on exécute le code “figé” dans l’image ; en local, on exécute le code du disque. - Variables d’environnement divergentes (valeurs, présence/absence, formats).
- Images construites différemment : build local avec cache, prod sans cache ; tags
latest; base images différentes ; dépendances installées à la volée. - Réseau / DNS / ports : en local on expose
ports, en prod on passe par un reverse proxy ; noms de services différents ;localhostutilisé à tort. - Volumes : un volume nommé en prod contient des données persistantes ; en local on repart de zéro ; parfois un volume masque un répertoire de l’image.
- Ordre de démarrage : Compose démarre vite, mais les services ne sont pas prêts (DB pas initialisée), d’où des “works locally” selon la vitesse machine.
- Différences d’OS : macOS/Windows vs Linux (filesystem case sensitivity, performances des bind mounts, UID/GID, line endings).
- Secrets gérés “à la main” en prod, en
.enven local.
Objectif : rendre explicite ce qui change entre local et prod, et garder identique ce qui doit l’être.
2) Principes d’architecture : ce qui doit être identique vs variable
Ce qui doit être identique (idéalement)
- Les images (mêmes tags/digests) : le binaire / runtime / dépendances doivent être identiques.
- La façon de démarrer l’app : même
ENTRYPOINT/CMD, mêmes migrations, même serveur HTTP. - La config applicative (structure) : mêmes clés, mêmes conventions, validation au démarrage.
Ce qui peut varier (mais doit être contrôlé)
- Les valeurs de variables (ex :
DATABASE_URL,LOG_LEVEL,SENTRY_DSN). - Les intégrations (ex : SMTP réel en prod vs Mailhog en local).
- L’exposition réseau (ports publiés localement vs reverse proxy en prod).
- Les ressources (CPU/RAM/replicas) — Compose n’est pas un orchestrateur complet, mais on peut cadrer.
3) Structure recommandée des fichiers Compose
On vise une base commune et des overrides :
compose.yml: base commune (prod-compatible).compose.override.yml: local (chargé automatiquement par Compose).compose.prod.yml: production (explicitement activé).- éventuellement
compose.test.yml: tests/CI.
Remarque : selon les versions, le fichier par défaut peut être
docker-compose.ymloucompose.yml. Docker Compose v2 acceptecompose.yml. Dans ce tutoriel :compose.yml.
Exemple d’arborescence
.
├── compose.yml
├── compose.override.yml
├── compose.prod.yml
├── .env # valeurs locales uniquement (non commité)
├── .env.example # exemple commité
├── app/
│ ├── Dockerfile
│ ├── package.json
│ └── src/...
└── infra/
└── nginx/...
4) Construire une image “prod-grade” utilisable en local
Le piège le plus fréquent : en local on utilise une image “dev” (avec hot reload, outils, dépendances de dev), et en prod une image “slim”. Résultat : comportements différents.
Approche recommandée : multi-stage build avec une cible prod et éventuellement une cible dev, mais en s’assurant que la cible prod est la référence.
Exemple Dockerfile (Node.js) multi-stage
# app/Dockerfile
FROM node:20-bookworm-slim AS base
WORKDIR /app
ENV NODE_ENV=production
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS prod
# Utilisateur non-root (important en prod)
RUN useradd -m appuser
USER appuser
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
But : en local, on peut toujours faire du hot reload via override, mais l’image prod reste la même base.
Construire et tagger localement
docker build -t monapp:local -f app/Dockerfile app
En CI, vous taggez plutôt avec un SHA :
GIT_SHA=$(git rev-parse --short HEAD)
docker build -t registry.example.com/monapp:${GIT_SHA} -f app/Dockerfile app
docker push registry.example.com/monapp:${GIT_SHA}
En production, vous déployez exactement registry.example.com/monapp:${GIT_SHA} (ou, mieux, un digest).
5) compose.yml : base commune, pensée pour la production
Voici un exemple volontairement réaliste : une app web + PostgreSQL + Redis. On évite les bind mounts ici, on privilégie des volumes nommés, des healthchecks, et une config explicite.
# compose.yml
name: monapp
services:
web:
image: registry.example.com/monapp:${APP_IMAGE_TAG:-local}
# Alternative si vous build en local via Compose :
# build:
# context: ./app
# dockerfile: Dockerfile
environment:
APP_ENV: ${APP_ENV:-production}
PORT: "3000"
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
interval: 10s
timeout: 3s
retries: 10
restart: unless-stopped
networks:
- backend
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB:-monapp}
POSTGRES_USER: ${POSTGRES_USER:-monapp}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
networks:
- backend
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
networks:
- backend
volumes:
db_data:
redis_data:
networks:
backend:
driver: bridge
Points importants
- Pas de
portsdans la base : en prod, vous n’exposez pas forcément directement ; vous passerez par un reverse proxy / load balancer. En local, on ajouteraportsvia override. depends_onaveccondition: service_healthy: réduit les démarrages “trop tôt”.- Variables obligatoires :
DATABASE_URL,REDIS_URL,POSTGRES_PASSWORDdoivent être définies. Ne mettez pas de valeurs par défaut dangereuses en prod. restart: unless-stopped: comportement plus proche d’une prod simple (redémarrage après crash).
6) compose.override.yml : accélérer le local sans casser la prod
Le fichier compose.override.yml est automatiquement pris en compte en local. On y met :
portspour accéder depuis la machine hôte ;- bind mounts pour le hot reload ;
- services de dev (Mailhog, Adminer, etc.) ;
- variables de dev.
Exemple :
# compose.override.yml
services:
web:
environment:
APP_ENV: development
LOG_LEVEL: debug
ports:
- "3000:3000"
# Bind mount du code pour itération rapide
volumes:
- ./app:/app
# Commande de dev (ex: nodemon)
command: ["npm", "run", "dev"]
db:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"
mailhog:
image: mailhog/mailhog:v1.0.1
ports:
- "8025:8025"
networks:
- backend
Attention aux bind mounts
Le bind mount ./app:/app masque ce qui est dans l’image à /app. Si l’image a un node_modules construit, il peut être remplacé par celui du host (ou absent), d’où des écarts.
Solutions fréquentes :
- Monter seulement
./app/src:/app/srcet gardernode_modulesdans l’image ; - Ou ajouter un volume anonyme pour
node_modules:
services:
web:
volumes:
- ./app:/app
- /app/node_modules
Cette technique évite que node_modules du host prenne le dessus.
7) compose.prod.yml : expliciter la prod (reverse proxy, pas de mounts)
En production, on veut :
- pas de bind mounts ;
- pas d’outils de dev ;
- des ports gérés par un reverse proxy (Nginx, Traefik, Caddy) ;
- des secrets hors
.envsi possible.
Exemple avec un Nginx simple (terminaison TLS souvent gérée ailleurs ; ici HTTP interne) :
# compose.prod.yml
services:
web:
environment:
APP_ENV: production
LOG_LEVEL: info
# pas de ports exposés directement
expose:
- "3000"
nginx:
image: nginx:1.27-alpine
depends_on:
web:
condition: service_healthy
ports:
- "80:80"
volumes:
- ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- backend
Exemple de config Nginx minimale :
# infra/nginx/default.conf
server {
listen 80;
location / {
proxy_pass http://web:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
8) Variables d’environnement : .env, interpolation, validation et pièges
8.1 .env n’est pas un “fichier de prod”
Compose charge automatiquement un fichier .env (dans le répertoire courant) pour l’interpolation (${VAR}). En local c’est pratique. En prod, c’est risqué si vous le copiez tel quel sur le serveur.
Bonnes pratiques :
- Commiter un
.env.exampledocumenté. - Ne jamais commiter
.envréel. - En prod, injecter les variables via le système (CI/CD, systemd, fichier sécurisé, vault…).
.env.example :
# .env.example
APP_IMAGE_TAG=local
POSTGRES_DB=monapp
POSTGRES_USER=monapp
POSTGRES_PASSWORD=change_me
DATABASE_URL=postgresql://monapp:change_me@db:5432/monapp
REDIS_URL=redis://redis:6379
8.2 Différence entre env_file: et .env
.env: sert principalement à interpoler danscompose.yml.env_file:: injecte des variables dans le conteneur.
Vous pouvez utiliser env_file en local, mais en prod préférez une injection contrôlée.
8.3 Valider la config au démarrage
Pour éviter les surprises, faites échouer l’app si une variable critique manque. Exemple (Node) :
// app/src/config.js
function required(name) {
const v = process.env[name];
if (!v) throw new Error(`Variable requise manquante: ${name}`);
return v;
}
export const config = {
databaseUrl: required("DATABASE_URL"),
redisUrl: required("REDIS_URL"),
};
Résultat : au lieu d’un comportement “bizarre” en prod, vous avez un crash immédiat, plus facile à diagnostiquer.
9) Secrets : éviter de mettre les mots de passe dans des variables en clair
Compose propose secrets, surtout utile en mode Swarm, mais on peut déjà structurer proprement.
Option A (simple) : variables d’environnement, mais gérées par le système
En prod, vous définissez POSTGRES_PASSWORD via un mécanisme sécurisé (CI/CD, fichier root-only, etc.) et vous ne le stockez pas dans Git.
Option B : fichiers montés en lecture seule
Vous pouvez monter un fichier secret :
services:
web:
volumes:
- /etc/monapp/secrets:/run/secrets:ro
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
Puis votre app lit le fichier. Beaucoup d’applications supportent le pattern *_FILE.
10) Réseau : arrêter d’utiliser localhost entre conteneurs
Dans Compose, chaque service est joignable par son nom sur le réseau du projet :
- DB :
db:5432 - Redis :
redis:6379 - Web :
web:3000
Donc, dans DATABASE_URL, utilisez db et non localhost.
Mauvais (dans un conteneur) :
DATABASE_URL=postgresql://monapp:pwd@localhost:5432/monapp
Bon :
DATABASE_URL=postgresql://monapp:pwd@db:5432/monapp
Pour diagnostiquer DNS/réseau :
docker compose exec web getent hosts db
docker compose exec web sh -lc "nc -vz db 5432"
docker compose exec web sh -lc "nc -vz redis 6379"
11) Volumes : persistance, masquage et migrations
11.1 Volume nommé vs bind mount
- Volume nommé (
db_data:) : géré par Docker, persistant, portable. - Bind mount (
./data:/var/lib/...) : dépend du FS hôte, permissions, chemins.
En production, préférez les volumes nommés (ou stockage géré par la plateforme). En local, volume nommé est souvent très bien aussi.
11.2 Lister et inspecter les volumes
docker volume ls
docker volume inspect monapp_db_data
11.3 Réinitialiser proprement l’environnement local
Pour repartir de zéro (attention, supprime les données) :
docker compose down -v
11.4 Migrations DB : rendez-les explicites et reproductibles
Évitez “je me connecte et je lance une commande à la main”. Créez un service one-shot :
services:
migrate:
image: registry.example.com/monapp:${APP_IMAGE_TAG:-local}
command: ["node", "dist/migrate.js"]
environment:
DATABASE_URL: ${DATABASE_URL}
depends_on:
db:
condition: service_healthy
networks:
- backend
Exécution :
docker compose run --rm migrate
En prod, vous pouvez faire la même chose avec les mêmes images.
12) Healthchecks : réduire les “ça démarre mais ça ne marche pas”
Sans healthchecks, depends_on ne garantit pas que la DB accepte les connexions. Avec healthchecks, vous synchronisez mieux.
Vérifier l’état :
docker compose ps
docker inspect --format='{{json .State.Health}}' monapp-db-1 | jq
Voir les logs :
docker compose logs -f db
docker compose logs -f web
13) Profils Compose : activer des services selon le contexte
Les profils permettent d’activer certains services uniquement quand demandé.
Exemple : mailhog uniquement en local :
services:
mailhog:
image: mailhog/mailhog:v1.0.1
profiles: ["dev"]
ports:
- "8025:8025"
Lancer avec le profil :
docker compose --profile dev up -d
En prod, si vous n’activez pas dev, mailhog ne démarre pas.
14) Stratégie de build : éviter les images “différentes” entre local et prod
14.1 Règle d’or : “build une fois, exécuter partout”
En CI :
- build de l’image
- tests
- push registry
- déploiement en prod en tirant la même image
14.2 Éviter latest
Ne déployez pas latest. Utilisez un tag immuable (SHA) ou un digest.
Récupérer le digest :
docker build -t registry.example.com/monapp:tmp -f app/Dockerfile app
docker push registry.example.com/monapp:tmp
docker inspect --format='{{index .RepoDigests 0}}' registry.example.com/monapp:tmp
Puis déployer registry.example.com/monapp@sha256:....
15) Commandes Compose “réelles” pour un workflow propre
15.1 Démarrer en local (avec override auto)
docker compose up -d --build
docker compose logs -f web
15.2 Démarrer en prod (avec fichier prod)
docker compose -f compose.yml -f compose.prod.yml up -d
15.3 Voir la config finale (très utile)
Pour comprendre ce qui est réellement appliqué :
docker compose config
docker compose -f compose.yml -f compose.prod.yml config
Cela permet de repérer :
- une variable non interpolée,
- un volume inattendu,
- une commande override,
- un port exposé par erreur.
15.4 Exécuter une commande dans un conteneur
docker compose exec web sh
docker compose exec db psql -U monapp -d monapp
15.5 Lancer une tâche one-shot
docker compose run --rm web node -v
docker compose run --rm migrate
15.6 Nettoyage
docker compose down
docker compose down -v
docker system df
16) Différences local vs prod : check-list anti-surprises
16.1 Système de fichiers et permissions (UID/GID)
En prod Linux, un conteneur non-root écrit parfois dans des répertoires non accessibles. En local avec bind mount, c’est encore plus visible.
Diagnostic :
docker compose exec web id
docker compose exec web ls -la /app
Solution : définir un user cohérent, ajuster permissions, éviter d’écrire dans le code monté, écrire dans /tmp ou un volume dédié.
16.2 Horloge, timezone, locale
Si vous parsez des dates, fixez explicitement.
Exemple :
services:
web:
environment:
TZ: Europe/Paris
16.3 Variables manquantes
Faites échouer tôt (validation). Et utilisez docker compose config pour vérifier l’interpolation.
16.4 Dépendances natives
Si vous compilez des dépendances (ex: bcrypt, sharp), build et run doivent être sur des bases compatibles. D’où l’intérêt de “build une fois”.
17) Exemple complet : mettre en place une approche “base + overrides” robuste
Étape 1 : créer .env local (non commité)
cp .env.example .env
# puis éditez .env et mettez un vrai mot de passe local
Étape 2 : lancer la stack local
docker compose up -d --build
docker compose ps
Étape 3 : vérifier la santé
curl -i http://localhost:3000/health
docker compose logs -f web
Étape 4 : exécuter les migrations
docker compose run --rm migrate
Étape 5 : simuler la prod localement (sans override)
Pour voir ce qui se passe “comme en prod”, ne chargez pas compose.override.yml :
docker compose -f compose.yml up -d
Ou avec la prod :
docker compose -f compose.yml -f compose.prod.yml up -d
C’est une pratique extrêmement efficace pour détecter :
- une dépendance au bind mount,
- une commande de dev,
- un port exposé manquant,
- une variable non définie.
18) Observabilité minimale : logs, métriques, et diagnostic réseau
Logs
docker compose logs --tail=200 web
docker compose logs -f
Ressources
docker stats
Inspecter un conteneur
docker inspect monapp-web-1
Tester la connectivité depuis web
docker compose exec web sh -lc "node -e \"console.log('ok')\""
docker compose exec web sh -lc "nc -vz db 5432 && echo DB_OK"
docker compose exec web sh -lc "nc -vz redis 6379 && echo REDIS_OK"
19) Quand Compose en production est acceptable (et quand ça ne l’est pas)
Compose peut être acceptable en production si :
- vous avez un seul serveur (ou quelques serveurs gérés manuellement),
- vous voulez une complexité minimale,
- vous avez un reverse proxy simple,
- vous avez une stratégie de build/push/pull propre,
- vous acceptez des limites (pas d’auto-scaling natif, rolling update limité, etc.).
Quand éviter Compose en prod :
- besoin de haute dispo multi-nœuds,
- déploiements progressifs, auto-healing avancé, scaling,
- politiques réseau avancées,
- exigences fortes de sécurité et de secrets intégrés.
Dans ces cas, Kubernetes/Nomad/Swarm/plateformes managées sont plus adaptées. Mais même alors, les principes de ce tutoriel restent valables : images immuables, config explicite, validation, séparation base/overrides.
20) Résumé : règles d’or pour éliminer la dérive
- Une image unique, immuable : “build une fois, exécuter partout”.
compose.ymldoit être prod-compatible (pas de bind mounts, pas de ports inutiles, healthchecks).compose.override.ymlcontient uniquement des optimisations de dev (ports, hot reload, outils).- Ne jamais utiliser
localhostpour parler à un autre service depuis un conteneur. - Valider la config au démarrage (variables requises).
- Utiliser
docker compose configpour voir la vérité. - Rendre les opérations critiques scriptables (migrations, seed, jobs).
- Éviter
latest, préférer tags immuables ou digests. - Gérer explicitement volumes, permissions, et masquage des répertoires.
- Tester régulièrement le mode “prod” en local (
-f compose.yml -f compose.prod.yml).
Annexes : commandes utiles (anti-paniques)
Voir les fichiers réellement pris en compte
docker compose ls
docker compose config
Redémarrer un service
docker compose restart web
Recréer un service après changement d’image
docker compose up -d --no-deps --force-recreate web
Supprimer les images non utilisées (avec prudence)
docker image prune
docker system prune
En appliquant cette méthode “base prod + overrides locaux”, vous obtenez un environnement local rapide sans trahir la production, et une production qui se comporte comme prévu parce qu’elle exécute exactement les mêmes artefacts. Si vous voulez, décrivez votre stack (langage, services, reverse proxy, CI/CD) et je peux proposer une déclinaison complète (Compose + Dockerfile + stratégie de tags + check-list de déploiement) adaptée à votre cas.