← Retour aux tutoriels

Docker Compose en local vs production : éliminer la dérive d’environnement et les mauvaises surprises

docker-composedockerdevopsdeploiementenvironnementsconfigurationci-cdbonnes-pratiques

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 :


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 :

  1. 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.
  2. Variables d’environnement divergentes (valeurs, présence/absence, formats).
  3. Images construites différemment : build local avec cache, prod sans cache ; tags latest ; base images différentes ; dépendances installées à la volée.
  4. Réseau / DNS / ports : en local on expose ports, en prod on passe par un reverse proxy ; noms de services différents ; localhost utilisé à tort.
  5. 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.
  6. 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.
  7. Différences d’OS : macOS/Windows vs Linux (filesystem case sensitivity, performances des bind mounts, UID/GID, line endings).
  8. Secrets gérés “à la main” en prod, en .env en 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)

Ce qui peut varier (mais doit être contrôlé)


3) Structure recommandée des fichiers Compose

On vise une base commune et des overrides :

Remarque : selon les versions, le fichier par défaut peut être docker-compose.yml ou compose.yml. Docker Compose v2 accepte compose.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


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 :

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 :

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 :

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 :

.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

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 :

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

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 :

  1. build de l’image
  2. tests
  3. push registry
  4. 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 :

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 :


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 :

Quand éviter Compose en prod :

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

  1. Une image unique, immuable : “build une fois, exécuter partout”.
  2. compose.yml doit être prod-compatible (pas de bind mounts, pas de ports inutiles, healthchecks).
  3. compose.override.yml contient uniquement des optimisations de dev (ports, hot reload, outils).
  4. Ne jamais utiliser localhost pour parler à un autre service depuis un conteneur.
  5. Valider la config au démarrage (variables requises).
  6. Utiliser docker compose config pour voir la vérité.
  7. Rendre les opérations critiques scriptables (migrations, seed, jobs).
  8. Éviter latest, préférer tags immuables ou digests.
  9. Gérer explicitement volumes, permissions, et masquage des répertoires.
  10. 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.