Trois Dockerfile différents. Un docker-compose.yml pour le dev, une copie pour les tests, une variante bricolée pour la CI GitHub Actions. Six mois plus tard, une variable d'environnement change côté pipeline, personne ne la répercute en local, et les bugs de parité s'accumulent silencieusement — jusqu'au jour où reproduire un échec CI sur sa machine devient un sport de combat. Depuis le 6 mars 2025, Docker Compose v2 est actif par défaut dans Docker Desktop 4.39, sans configuration manuelle. C'est le bon moment pour remettre de l'ordre une bonne fois pour toutes. Voici l'architecture que j'utilise sur mes projets Symfony 7.2 / PHP 8.4 : un fichier de base, des override files ciblés, des profils pour les services optionnels comme Mailpit, et une CI GitHub Actions qui joue exactement les mêmes cartes que ta machine locale. Zéro duplication.
Le piège des environnements divergents
Le schéma classique : tu crées un docker-compose.yml en développement avec FrankenPHP, Xdebug et Mailpit. Quand vient le moment d'écrire les tests d'intégration, tu en fais une copie — tu retires Mailpit, tu changes le nom de la base. Puis la CI réclame une troisième variante parce que les ports diffèrent ou que les secrets sont injectés par le runner. Quelques sprints plus tard, les trois fichiers ont divergé. Une migration Doctrine tourne bien en test mais échoue en CI parce que la version de PostgreSQL diffère d'un cran. Un e-mail de notification ne part plus en dev parce que MAILER_DSN a été modifié dans un seul fichier. La solution n'est pas un monolithe de configuration : c'est d'utiliser les mécanismes que Docker Compose propose nativement — les override files et les profils.
Un docker-compose.yml de base : le contrat partagé
Le fichier racine docker-compose.yml définit la topologie de l'application sans opinion sur l'environnement d'exécution : services, contexte de build, healthchecks, variables communes. Aucun port exposé côté hôte, aucun volume de développement, aucune configuration Xdebug. La target de build par défaut pointe vers app — la cible de production. Les overrides surchargeront ce qui doit l'être. Versionné dans le dépôt, ce fichier est la référence incontestable partagée par tous les environnements.
# docker-compose.yml — contrat partagé entre tous les environnements
services:
php:
build:
context: .
target: app
environment:
APP_ENV: ${APP_ENV:-prod}
APP_SECRET: ${APP_SECRET}
DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD:-app}@db:5432/app?serverVersion=16&charset=utf8
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 5s
retries: 5Remarque l'absence de volume sur db dans ce fichier de base — c'est intentionnel. L'override dev ajoutera un volume persistant ; l'override test utilisera tmpfs pour une base en RAM. Le Dockerfile suit la même logique multi-stage : une cible base commune, une cible dev avec Xdebug, une cible app avec l'autoloader optimisé pour la production et la CI.
# Dockerfile
FROM dunglas/frankenphp:1-php8.4 AS base
ENV COMPOSER_ALLOW_SUPERUSER=1 \
SERVER_NAME=":80"
WORKDIR /app
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN install-php-extensions \
apcu \
intl \
opcache \
pdo_pgsql \
zip
COPY composer.json composer.lock symfony.lock* ./
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist --no-progress
# Target développement : Xdebug activé, sources montées en volume
FROM base AS dev
RUN install-php-extensions xdebug
COPY . .
# Target production et CI : sources copiées, autoloader optimisé
FROM base AS app
COPY . .
RUN composer dump-autoload --classmap-authoritative --no-devL'override dev : FrankenPHP, Xdebug et volumes locaux
Docker Compose charge automatiquement docker-compose.override.yml quand il est présent dans le répertoire courant — sans passer -f manuellement. C'est dans ce fichier que tout ce qui est spécifique au développement prend place : la target dev (avec Xdebug), les ports exposés côté hôte pour accéder à l'application et à PostgreSQL depuis l'IDE, les volumes pour le hot reload des sources, les volumes Caddy pour les certificats locaux, et la variable MAILER_DSN qui redirige tous les e-mails vers Mailpit. Un simple docker compose up -d suffit pour retrouver l'environnement complet.
# docker-compose.override.yml — dev uniquement (chargé automatiquement)
services:
php:
build:
target: dev # active la target avec Xdebug
environment:
APP_ENV: dev
APP_SECRET: dev-secret-not-for-prod
XDEBUG_MODE: ${XDEBUG_MODE:-debug}
XDEBUG_CONFIG: client_host=host.docker.internal
MAILER_DSN: smtp://mailpit:1025
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- .:/app # hot reload : les modifications locales sont visibles immédiatement
- caddy_data:/data
- caddy_config:/config
db:
ports:
- "5432:5432" # accès direct depuis l'IDE (DataGrip, TablePlus…)
volumes:
- db_data:/var/lib/postgresql/data # persistance entre les redémarrages
mailpit:
profiles:
- mail
image: axllent/mailpit:v1
ports:
- "1025:1025"
- "8025:8025"
volumes:
caddy_data:
caddy_config:
db_data:Mailpit et les profils Docker Compose : services à la carte
Besoin d'un expert Symfony ?
Réserver un appel →Les profils Docker Compose permettent de déclarer des services optionnels sans les démarrer par défaut. Un service portant un champ profiles est invisible pour docker compose up tant qu'il n'est pas activé explicitement avec --profile. C'est idéal pour Mailpit : certains développeurs travaillent sur des fonctionnalités sans e-mail, d'autres en ont besoin en permanence. Le service est configuré une seule fois dans l'override, activé à la demande. Dans le fichier .env.local Symfony, MAILER_DSN=smtp://mailpit:1025 — Mailpit intercepte tout et expose son interface web sur http://localhost:8025.
# démarrer l'environnement de dev sans Mailpit
docker compose up -d
# démarrer avec Mailpit activé
docker compose --profile mail up -d
# accéder à l'interface web de Mailpit
open http://localhost:8025
# arrêter tous les services (y compris les profils actifs)
docker compose --profile mail downL'override test : PostgreSQL en RAM, PHPUnit isolé
Pour les tests, docker-compose.override.yml ne s'applique pas — il n'est chargé que lorsqu'aucun flag -f n'est passé explicitement. En combinant docker-compose.yml et docker-compose.test.yml avec deux flags -f, on obtient un environnement minimal et déterministe. La target dev embarque PHPUnit. La variable APP_ENV=test active le kernel Symfony de test. La base de données porte un nom dédié (app_test) et tourne entièrement en RAM grâce à tmpfs — même moteur PostgreSQL qu'en production, vitesse d'une base en mémoire, aucun effet de bord entre les runs, aucune donnée de dev qui risque de polluer les assertions. Puisque le fichier de base ne monte aucun volume sur db, l'option tmpfs s'applique proprement, sans conflit.
# docker-compose.test.yml — utilisé explicitement avec -f
services:
php:
build:
target: dev # PHPUnit et Xdebug disponibles
environment:
APP_ENV: test
APP_SECRET: test-secret
DATABASE_URL: postgresql://app:app@db:5432/app_test?serverVersion=16&charset=utf8
volumes:
- .:/app
db:
environment:
POSTGRES_DB: app_test # base dédiée aux tests
tmpfs:
- /var/lib/postgresql/data # en RAM : rapide, sans résidu entre les runsGitHub Actions : CI propre avec les mêmes fichiers qu'en local
En CI, la même logique s'applique : on passe -f docker-compose.yml -f docker-compose.test.yml à chaque commande docker compose. Pas de docker-compose.ci.yml à maintenir en parallèle, pas de variables secrètes sur la structure des services. Le flag --wait sur docker compose up garantit que PostgreSQL est sain — healthcheck passé — avant de lancer les migrations. C'est une feature disponible nativement depuis Docker Compose v2.1, bien présente dans les runners GitHub en mars 2025. Si les tests passent en local avec cette commande, ils passent en CI — par construction.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build images
run: docker compose -f docker-compose.yml -f docker-compose.test.yml build --pull
- name: Start database (attend le healthcheck)
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up -d --wait db
- name: Run migrations
run: |
docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm php \
php bin/console doctrine:migrations:migrate --no-interaction
- name: Run PHPUnit
run: |
docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm php \
php bin/phpunit --testdox
- name: Teardown
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.test.yml down -vLes commandes du quotidien
- Démarrer le dev (sans Mailpit) :
docker compose up -d - Démarrer avec Mailpit :
docker compose --profile mail up -d - Lancer PHPUnit en local (identique à la CI) :
docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm php php bin/phpunit - Appliquer les migrations de test :
docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm php php bin/console doctrine:migrations:migrate --no-interaction - Ouvrir un shell dans le conteneur dev :
docker compose exec php bash - Rebuild après un changement dans le Dockerfile :
docker compose build --no-cache php - Tout stopper et nettoyer les volumes :
docker compose down -v
Trois fichiers, zéro duplication, environnements cohérents du laptop à la CI. La prochaine fois qu'un développeur rejoint le projet, un git clone suivi de docker compose --profile mail up -d suffit — aucune doc d'installation à maintenir. Si ton projet Symfony traîne encore des fichiers docker-compose hérités, plusieurs Dockerfile qui divergent ou une CI qui se comporte différemment de la machine locale, un Bear Scan permet de poser un diagnostic précis sur l'infrastructure de développement et de définir une roadmap de remise en ordre, sans tout recasser d'un coup.
Besoin d'un expert Symfony ?
20 ans d'expérience sur l'écosystème PHP/Symfony.