Vous avez une super app Symfony qui tourne (pas encore ?) avec Docker et vous voulez déclencher automatiquement sa mise en production lorsque vous faites un push dans une branche de votre repo. Respirez un grand coup, détendez-vous, vous êtes au bon endroit.
Avant de commencer, vérifions les bases. Simple, basique. Basique, simple.
Le déploiement continu, c'est quoi ?
Accueillez à nouveau le mot CD dans votre vocabulaire. Pas pour désigner le CD du groupe de rock de votre enfance, non, les choses ont bien changé. On parle ici de Continuous Deployment, l'automatisation du déploiement de vos apps sur vos serveurs de production en appuyant sur un gros bouton rouge.
Elle est pas belle la vie ?
Le cas d'étude
Sans plus attendre, on va s'intéresser à la stack technique suivante : Docker, Symfony 8, PHP 8.5, Tabler (Bootstrap 5) et React. Pour pimenter un peu le tout, j'ai utilisé Vite avec Symfony Reprise pour me rapprocher d'un cas concret. J'ai donc des dépendances JS et SCSS à installer avec Yarn, à compiler, à minifier, etc.
La magnifique démo de cet article : 👉https://labs.silarhi.fr/
L'idée est qu'à chaque push sur une branche git (main en l'occurrence), le serveur de CD va build la nouvelle version de l'image Docker qui correspond à notre app, le déployer sur le Hub Docker et appeler un script de déploiement sur notre serveur via SSH. En gros, le workflow ressemble à ça :
Crédits : OpenClassrooms x Paint
Sans plus attendre, voyons à quoi ressemble le Dockerfile de production et/ou staging de notre app :
#syntax=docker/dockerfile:1.27-labs
# Dockerfile
# Versions
FROM silarhi/php-apache:8.5-frankenphp-alpine AS php_upstream
FROM node:24-alpine AS node_upstream
# Base with extensions
FROM php_upstream AS php_base
RUN install-php-extensions exif gd imagick
# Composer deps (cached)
FROM php_base AS php_builder
WORKDIR /app
COPY --link composer.json composer.lock symfony.lock ./
RUN --mount=type=cache,target=/root/.composer \
APP_ENV=prod composer install --no-interaction --no-dev --no-scripts --prefer-dist
# Node deps (cached separately from build)
FROM node_upstream AS node_deps
WORKDIR /app
COPY --from=php_builder --link /app/vendor ./vendor
COPY --link package.json yarn.lock ./
RUN --mount=type=cache,target=/root/.yarn \
YARN_CACHE_FOLDER=/root/.yarn yarn install --frozen-lockfile
# Asset build
FROM node_upstream AS node_builder
WORKDIR /app
COPY --from=node_deps --link /app/node_modules ./node_modules
COPY --from=node_deps --link /app/vendor ./vendor
COPY --link package.json vite.config.js yarn.lock ./
COPY --link assets ./assets
RUN mkdir -p public && yarn build
# Final
FROM php_base
EXPOSE 80
WORKDIR /app
ARG APP_VERSION=dev
ARG GIT_COMMIT=master
ENV APP_VERSION="${APP_VERSION}"
ENV GIT_COMMIT="${GIT_COMMIT}"
COPY --from=php_builder --link /app/vendor ./vendor
COPY --from=node_builder --link /app/public/build /app/public/build
COPY --link --exclude=assets --exclude=docker . .
# Config
COPY --link docker/php.ini $PHP_INI_DIR/conf.d/app.ini
RUN mkdir -p var var/storage && \
composer dump-autoload --optimize --classmap-authoritative --no-dev --no-interaction && \
APP_ENV=prod bin/console cache:clear --no-warmup && \
APP_ENV=prod bin/console cache:warmup && \
# We don't use DotEnv component as docker-compose will provide real environment variables
echo "<?php return [];" > .env.local.php && \
chown -R www-data:www-data var && \
rm -rf /root/.cache
Avec ce Dockerfile multi-stage, on a un container tout chaud prêt pour la production : les dépendances Composer et Yarn sont installées dans des stages dédiés (et mises en cache par BuildKit), les fichiers JS et CSS sont compilés et minifiés par Vite, et le cache Symfony est déjà généré. Le tout tourne sur FrankenPHP, prêt à être délivré à vos millions de visiteurs par votre nouveau Raspberry Pi flambant neuf.
L'objectif est maintenant de réussir à build cette image automatiquement lors d'un nouveau push dans git, et à déployer l'app dans la foulée.
Dans le vif du sujet de la CD
Il existe pléthore d'outils pour arriver à nos fins :
Aujourd'hui, on va aborder CircleCI. N'hésitez pas à laisser un commentaire si vous voulez un tuto avec un autre outil de CI !
La CD avec CircleCI
CircleCI a l'avantage d'être gratuit dans son offre de base. On peut faire pas mal de choses avec cet outil, mais on va se concentrer sur les 4 opérations clés de notre flow :
- Redescendre du code dans une VM éphémère
- Build l'image Docker
- Push l'image sur le Docker Hub
- Appeler un script shell de déploiement sur le serveur de production
Pour ça, on va créer un fichier .circleci/config.yml à la racine du projet :
# .circleci/config.yml
version: 2.1
orbs:
docker: circleci/docker@4.0.1
jobs:
build:
docker:
- image: cimg/base:2026.10
environment:
IMAGE_NAME: silarhi/symfony-docker-ci
steps:
- checkout # Étape 1
- setup_remote_docker:
docker_layer_caching: true
# Étapes 2 & 3
- docker/check:
docker_password: DOCKER_PWD
- docker/build:
image: $IMAGE_NAME
tag: 1.$CIRCLE_BUILD_NUM,latest
extra_build_args: --build-arg APP_VERSION=1.$CIRCLE_BUILD_NUM --build-arg GIT_COMMIT=${CIRCLE_SHA1:0:7}
- docker/push:
image: $IMAGE_NAME
tag: 1.$CIRCLE_BUILD_NUM
- when:
condition:
equal: [main, << pipeline.git.branch >>]
steps:
- docker/push:
image: $IMAGE_NAME
tag: latest
step_name: Docker push latest
deploy: # Étape 4
machine:
image: ubuntu-2404:current
steps:
- add_ssh_keys:
fingerprints:
- 'de:c3:47:8e:28:31:55:01:9d:f7:08:f9:df:8e:79:e0'
- run:
name: 'Deploy image to production'
command: |
ssh ${PRODUCTION_SERVER_USER}@${PRODUCTION_SERVER_IP} "cd ${PRODUCTION_SERVER_PATH} && ./deploy.sh"
# On exécute ces étapes lors d'un commit sur la branche main uniquement
workflows:
build-and-deploy:
jobs:
- build
- deploy:
requires:
- build
filters:
branches:
only: main
Ne fuyez pas en voyant ce fichier, on va le détailler un peu :
-
version: 2.1permet d'utiliser les orbs, des paquets de configuration réutilisables. Ici, l'orb officielcircleci/dockerfournit des commandes toutes prêtes pour build et push nos images. - À chaque push, un container Docker est créé par CircleCI. On spécifie l'image qu'on veut utiliser avec la directive
image: cimg/base:2026.10. CircleCI nous laisse le choix d'utiliser des images déjà toutes prêtes conçues spécialement pour la CI/CD, autant les utiliser. - Étape 1 :
checkout. Il faut bien sûr redescendre le code de GitHub dans le container si on veut l'utiliser après.setup_remote_dockernous donne ensuite un moteur Docker distant, avec le cache des layers activé (docker_layer_caching) pour accélérer les builds suivants. - Étapes 2 & 3 :
docker/checkse connecte au Docker Hub,docker/buildconstruit l'image etdocker/pushla pousse sur le registre. Le tag1.<numéro de build>est toujours poussé, le taglatestuniquement sur la branchemaingrâce à la conditionwhen. Il y a cependant 2 choses à remarquer :- L'utilisation de
extra_build_argspour passer des--build-argàdocker build. Ces arguments sont complètement optionnels, c'est juste pour vous montrer qu'on peut utiliser le numéro de commit dans notre app Symfony via une variable d'environnement. Ça peut être utile par exemple si vous utilisez les releases de Sentry pour tracker quels commits sont particulièrement générateurs de problèmes. - L'utilisation de variables non déclarées telles que
PRODUCTION_SERVER_IPouDOCKER_PWD. En fait, pour des raisons de sécurité, CircleCI permet de définir des variables d'environnement secrètes qui ne seront pas affichées dans les logs de CircleCI. Vous pouvez les définir dans l'interface de CircleCI :
- L'utilisation de
Les variables d'environnement dans CircleCI
- Étape 4 :
-
fingerprintspermet d'utiliser la clé privée de votre serveur de production dans le container éphémère pour pouvoir se connecter par SSH sans utiliser de password. Veillez à bien sécuriser vos accès à CircleCI, car celui-ci devient désormais un élément critique de votre infra. Celui qui a accès à votre CircleCI peut avoir accès à votre serveur de production. - On exécute le script de déploiement sur le serveur de prod via la commande
cd ${PRODUCTION_SERVER_PATH} && ./deploy.sh
-
Le script de déploiement
#!/bin/bash
set -e
#Download new image version
docker compose pull
#Set maintenance mode to perform critical operations (database, upgrades, ...)
APP_MAINTENANCE=1 docker compose up -d
#i.e: docker compose exec -T app bin/console doctrine:migration:migrate -n
sleep 10; #for demo purpose
#back to prod
docker compose up -d
L'hébergement sur la production
# /apps/labs.silarhi.fr/ci/docker-compose.yml
services:
app:
image: silarhi/symfony-docker-ci:latest
container_name: lab_ci_app
volumes:
- storage:/app/var/storage
environment:
- APP_MAINTENANCE
env_file:
- app.env
labels:
- "traefik.enable=true"
- "traefik.http.routers.labs-ci.rule=Host(`labs.silarhi.fr`)"
- "traefik.http.routers.labs-ci.entrypoints=websecure"
- "traefik.http.routers.labs-ci.tls=true"
restart: unless-stopped
networks:
- web
volumes:
storage:
networks:
web:
external: true
🤓 « C'est quoi ces labels bizarres ?! »
J'utilise Traefik pour gérer les instances de Docker sur la production. Si ça vous intéresse, n'hésitez pas à jeter un oeil à l'article que j'ai écrit à ce sujet.
La gestion de la mise à jour
Durant le déploiement, on définit une variable d'environnement APP_MAINTENANCE. Un petit tweak sur le contrôleur frontal de Symfony (public/index.php) permet de renvoyer une page de maintenance (avec un code HTTP 503) durant la mise en prod, et le tour est joué.
// public/index.php
use App\Kernel;
use Symfony\Component\HttpFoundation\Response;
require_once \dirname(__DIR__) . '/vendor/autoload_runtime.php';
return static function (array $context) {
if ($context['APP_MAINTENANCE'] ?? false) {
$html = file_get_contents(__DIR__ . '/../maintenance.html');
return new Response($html, Response::HTTP_SERVICE_UNAVAILABLE);
}
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
Voilà ! Votre app est désormais fin prête à être déployée automatiquement pour votre plus grand bonheur. N'hésitez pas à laisser un commentaire si vous utilisez un autre workflow pour vos déploiements !




Top comments (0)