Le Makefile qui traîne dans chaque repo PHP, avec ses docker build -t mon-app:latest --build-arg APP_ENV=prod . imbriqués dans des fonctions shell bricolées entre deux sprints, a un successeur officiel. Docker Bake est passé en General Availability le 5 février 2025 avec Docker Desktop 4.38 — et c'est une rupture nette dans la manière de gérer les builds multi-images. Plus de scripts fragiles, plus de duplication entre docker-compose.override.yml et un Makefile jamais à jour : un seul fichier HCL déclaratif, versionné avec le code, orchestre ta stack Symfony de A à Z. Du Dockerfile multi-stage à la CI GitHub Actions, cet article te montre comment mettre en place cette infrastructure de bout en bout.
Ce que Bake résout que ton Makefile ne peut pas
Un Makefile de build Docker est un assemblage de règles shell qui se fragmentent dès qu'on ajoute un deuxième environnement. La target build-prod diffère de build-staging par trois arguments, mais elles partagent 80 % des options. On finit par copier-coller, par oublier de mettre à jour l'une quand on touche l'autre, et par passer une heure à déboguer un --build-arg manquant en CI. Docker Bake introduit l'héritage entre targets, les variables interpolées, les groupes de build et le cache BuildKit natif — tout ça dans un format que ton IDE peut valider et que git blame peut tracer. Cinq avantages concrets sur une stack Symfony réelle :
- Héritage de targets : une base _php-common héritée par php et php-prod, sans ligne dupliquée.
- Variables interpolées : REGISTRY, TAG, APP_ENV pilotent l'ensemble depuis le shell ou la CI.
- Groupes composables : docker buildx bake production lance php-prod et nginx-prod en parallèle, sans script intermédiaire.
- Cache BuildKit intégré : cache-from/cache-to configurés par target, pas dans un script wrapper.
- Multi-plateforme déclaratif : platforms = ["linux/amd64", "linux/arm64"] dans la target, sans option ad hoc.
Structure du fichier docker-bake.hcl
Le fichier s'appelle docker-bake.hcl par convention et se place à la racine du dépôt. BuildKit le découvre automatiquement quand tu lances docker buildx bake. HCL (HashiCorp Configuration Language) est le même format que Terraform : syntaxe lisible, pas de YAML imbriqué, interpolation de variables native. Les targets dont le nom commence par un underscore sont internes — elles ne sont pas buildées directement, uniquement héritées. Voici la structure complète pour une stack Symfony avec PHP-FPM et Nginx :
// docker-bake.hcl
variable "REGISTRY" {
default = "ghcr.io/acme/symfony-app"
}
variable "TAG" {
default = "latest"
}
variable "APP_ENV" {
default = "dev"
}
// ── Groupes ───────────────────────────────────────────────────────────────
group "default" {
targets = ["php", "nginx"]
}
group "production" {
targets = ["php-prod", "nginx-prod"]
}
// ── Base partagée (target interne, non buildée directement) ───────────────
target "_php-common" {
context = "."
dockerfile = "docker/php/Dockerfile"
args = {
PHP_VERSION = "8.4"
APP_ENV = APP_ENV
}
}
// ── PHP dev ───────────────────────────────────────────────────────────────
target "php" {
inherits = ["_php-common"]
target = "dev"
tags = ["${REGISTRY}/php:${TAG}"]
}
// ── PHP prod (multi-arch + cache registry) ────────────────────────────────
target "php-prod" {
inherits = ["_php-common"]
target = "prod"
tags = ["${REGISTRY}/php:${TAG}"]
platforms = ["linux/amd64", "linux/arm64"]
cache-from = ["type=registry,ref=${REGISTRY}/php:buildcache"]
cache-to = ["type=registry,ref=${REGISTRY}/php:buildcache,mode=max"]
}
// ── Nginx ─────────────────────────────────────────────────────────────────
target "nginx" {
context = "."
dockerfile = "docker/nginx/Dockerfile"
tags = ["${REGISTRY}/nginx:${TAG}"]
}
target "nginx-prod" {
inherits = ["nginx"]
platforms = ["linux/amd64", "linux/arm64"]
tags = ["${REGISTRY}/nginx:${TAG}"]
cache-from = ["type=registry,ref=${REGISTRY}/nginx:buildcache"]
cache-to = ["type=registry,ref=${REGISTRY}/nginx:buildcache,mode=max"]
}La clé est la target _php-common : elle définit le contexte, le Dockerfile et les arguments communs. php et php-prod en héritent et n'ajoutent que ce qui diffère — le stage Docker ciblé, les plateformes, le cache. Si demain tu passes à PHP 8.5, tu touches une seule ligne. Le cache est configuré uniquement sur les targets de production : inutile de pousser du cache BuildKit depuis une machine de dev.
Dockerfile multi-stage PHP 8.4-FPM : dev et prod dans un seul fichier
Un seul docker/php/Dockerfile expose trois stages nommés. Le stage vendor installe les dépendances Composer et compile les extensions PHP — c'est la couche la plus lente, et elle sera mise en cache par BuildKit dès que composer.lock n'a pas bougé. Le stage dev en hérite et ajoute Xdebug. Le stage prod repart d'une image Alpine propre et copie uniquement /app depuis vendor : aucun outil de build ne finit dans l'image finale. Docker Bake choisit le bon stage via target = "dev" ou target = "prod" dans le HCL.
ARG PHP_VERSION=8.4
# ── Stage 1 : vendor (dépendances partagées) ─────────────────────────────
FROM php:${PHP_VERSION}-fpm-alpine AS vendor
RUN apk add --no-cache git unzip libpq-dev icu-dev \
&& docker-php-ext-install pdo_pgsql intl opcache
COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-scripts \
--no-autoloader \
--prefer-dist
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative
# ── Stage 2 : dev (Xdebug 3.4, toutes les deps) ──────────────────────────
FROM vendor AS dev
RUN pecl install xdebug-3.4.0 \
&& docker-php-ext-enable xdebug
COPY docker/php/conf.d/xdebug.ini /usr/local/etc/php/conf.d/
# ── Stage 3 : prod (image minimale, sans outils de build) ────────────────
FROM php:${PHP_VERSION}-fpm-alpine AS prod
RUN apk add --no-cache libpq icu-libs \
&& docker-php-ext-install pdo_pgsql intl opcache
WORKDIR /app
COPY --from=vendor /app /app
COPY docker/php/conf.d/opcache.ini /usr/local/etc/php/conf.d/
USER www-dataLe stage prod repart d'une image Alpine vierge et ne contient ni git, ni composer, ni les headers de compilation. L'image finale est 60 à 80 Mo plus légère qu'un build naïf. La config opcache à placer dans docker/php/conf.d/opcache.ini — validate_timestamps=0 est critique en prod pour ne pas relire le disque à chaque requête :
[opcache]
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.preload=/app/var/cache/prod/App_KernelProdContainer.preload.php
opcache.preload_user=www-dataBesoin d'un expert Symfony ?
Réserver un appel →Basculer de dev à prod sans dupliquer la config
Les variables APP_ENV et TAG pilotent l'ensemble depuis le shell — sans toucher au HCL ni au Dockerfile. En local tu veux Xdebug et les images non poussées. En CI tu veux l'image prod multi-arch poussée vers GHCR avec un tag de SHA Git. Trois commandes, zéro duplication :
# Dev local : groupe default (php + nginx), images non poussées
docker buildx bake
# Staging : push un tag de branche avec le SHA court
APP_ENV=staging TAG=staging-$(git rev-parse --short HEAD) \
docker buildx bake --push production
# Prod : push depuis la CI avec le tag de version
APP_ENV=prod TAG=v1.4.2 \
docker buildx bake --push productionCache BuildKit partagé entre jobs GitHub Actions
Le gain de temps le plus visible en CI vient du cache BuildKit stocké dans le registry. Sans cache, chaque job re-télécharge Alpine, re-compile les extensions PHP et re-installe Composer. Avec cache-from/cache-to en mode max, BuildKit stocke chaque couche intermédiaire et les réutilise dès que le contexte n'a pas changé. En pratique, un build Symfony qui prenait 4 minutes passe à 40 secondes sur les commits qui ne touchent ni le Dockerfile ni composer.lock. Le workflow complet avec docker/bake-action :
# .github/workflows/build.yml
name: Build & Push
on:
push:
branches: [main]
tags: ['v*.*.*']
permissions:
contents: read
packages: write
jobs:
bake:
name: Docker Bake → GHCR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Login GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Résoudre le tag
id: tag
run: echo "value=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
- name: Bake production
id: bake
uses: docker/bake-action@v4
with:
files: ./docker-bake.hcl
targets: production
push: true
provenance: false
env:
REGISTRY: ghcr.io/${{ github.repository_owner }}
TAG: ${{ steps.tag.outputs.value }}
APP_ENV: prodPush vers GHCR et digest immuable
Un tag Docker est mutable : ghcr.io/acme/php:latest peut pointer sur des images différentes au fil des push. Pour un déploiement reproductible et auditable, ce qui compte c'est le digest — l'empreinte SHA256 immuable calculée sur le contenu réel de l'image. docker/bake-action expose les metadata de build dans un output JSON après le job. Tu peux en extraire les digests et les passer à ton manifeste Helm ou Kustomize :
- name: Afficher les digests par target
run: |
echo '${{ steps.bake.outputs.metadata }}' \
| jq -r 'to_entries[] | "\(.key): \(.value["containerimage.digest"])"'
# Exemple de sortie :
# php-prod: sha256:a1b2c3d4e5f6...
# nginx-prod: sha256:b2c3d4e5f6a1...Le digest se passe directement à docker run ou à ton orchestrateur : ghcr.io/acme/php@sha256:a1b2c3…. Si l'image a été altérée entre le build et le déploiement — registry compromis ou erreur de tag — le pull échoue immédiatement. C'est la garantie d'immuabilité que le tag seul ne peut pas offrir, particulièrement critique quand tu déploies plusieurs réplicas qui doivent tourner exactement sur la même couche.
Ce que tu gagnes en production
Un fichier docker-bake.hcl de 60 lignes remplace un Makefile de 200 lignes et trois scripts CI distincts. Les targets héritent, les variables s'injectent depuis le shell, le cache BuildKit réduit les temps de build de 80 % sur les commits courants, et les digests garantissent la traçabilité de chaque image déployée. Docker Bake n'est plus une feature expérimentale : depuis le 5 février 2025, c'est l'outil officiel de Docker pour les builds déclaratifs, avec une GA qui engage la backward-compatibility. L'adopter maintenant sur ta stack Symfony te donne une infrastructure de build solide et standardisée — bien avant que ce soit la norme dans les équipes PHP. Si tu veux un regard externe sur ton pipeline actuel — Dockerfile non optimisé, layers redondants, CI trop lente, secrets mal gérés — un Bear Scan peut identifier les points à fort impact en une session de travail.
Besoin d'un expert Symfony ?
20 ans d'expérience sur l'écosystème PHP/Symfony.