Accéder au contenu principal

15min.

Déploiement On-Premise – Partie 1 – Le socle Docker

Dans cet article, nous vous expliquons notre approche de déploiement hybride pour une application Symfony conteneurisée avec Docker. Ce système permet un déploiement à la fois sur des serveurs connectés à Internet et en mode local (on-premise) pour les zones de travail sans connectivité réseau.

Section intitulée le-contexteLe contexte

Nous avons récemment entrepris la refonte complète de l’application ArSol pour l’équipe archéologique de l’université de Tours. Le logiciel original, une application desktop, était obsolète. Nous l’avons entièrement modernisé en développant une application web sur mesure… avec une interface utilisateur considérablement rajeunie de plusieurs décennies propulsée avec Symfony 7.4, PHP 8.4 et FrankenPHP.

Une des particularités du projet, c’est que l’application peut être utilisée sur des lieux de fouilles archéologiques ne disposant pas de réseau. Pour plusieurs raisons, l’idée d’une PWA a été écartée assez rapidement. Nous avons plutôt opté pour un mode de déploiement On-Premise : chaque site de fouille pourra ainsi faire tourner l’infrastructure complète (serveur web, bases de données et application Symfony) sur une machine locale. Les contraintes métiers nous ont permis de développer, sans trop de complexité, un système de verrouillage partiel de l’application (pour éviter tout conflit) et une synchronisation des données entre le SaaS (c’est-à-dire la production, le serveur central) et les instances On-Premise.

Le schéma des différentes instances

Je vais vous montrer aujourd’hui comment nous avons mis en place ce déploiement On-Premise (que j’abrégerai OP dans la suite de cet article). D’abord, nous verrons comment se présente la stack Docker, puis comment nous avons automatisé la création des images et le déploiement.

Section intitulée la-base-dockerLa base Docker

Pour ce projet, nous avons choisi de déployer l’application via des images Docker, aussi bien pour la production que pour les instances OP. Ainsi, ce sont les mêmes images Docker qui seront utilisées dans le mode SaaS et dans le mode OP. L’activation des différentes options et feature flags repose exclusivement sur des variables d’environnement.

Section intitulée la-stack-de-devLa stack de dev

Sur tous nos projets, nous utilisons docker-starter et ArSol n’y a pas fait exception. Ce squelette fournit une stack Docker complète, avec tout ce qu’il faut dedans pour que chaque intervenant du projet puisse le faire tourner en local facilement.

Docker-starter s’est amélioré au fil des années pour offrir une excellente DX à tous nos développeurs, qu’ils soient développeurs PHP ou intégrateurs, qu’ils soient à l’aise avec le fonctionnement de Docker ou pas du tout.

Pour cela, toute la stack est pilotée par des tasks Castor 🦫 :

  • castor start : construit les images si nécessaire, les démarre, installe les dépendances Composer/Yarn/npm, build les assets front, etc. Bref, cette seule commande suffit pour rendre le projet complètement fonctionnel en local, que ce soit au premier lancement ou aux lancements suivants ;
  • castor migrate : joue les migrations Doctrine ;
  • castor stop : stoppe toute la stack ;
  • et plein d’autres tasks pour les power users ou pour ceux qui veulent lancer une tâche particulière, par exemple ré-installer les dépendances, exécuter les tests, etc.

Cette infrastructure dockerisée est parfaite pour le développement :

  • Les tâches castor fournies couvrent une bonne partie des besoins du quotidien ;
  • Le code du projet n’est pas copié/collé dans les conteneurs, mais « monté » dans des volumes pour chaque conteneur. Ainsi, les modifications dans le code sont directement disponibles dans les conteneurs, pas de build/restart à faire à chaque modification.

Section intitulée comment-passer-en-productionComment passer en production ?

Si Docker-starter est parfait pour l’environnement de développement, il ne doit toutefois pas être utilisé tel quel en production.

En production, la priorité est la fiabilité, la sécurité, la rapidité de déploiement et la reproductibilité de l’environnement. Contrairement à l’environnement de développement où le montage de volume permet l’itération rapide du code, en production, on cherche à minimiser les étapes au moment du déploiement.

Avoir des images pré-construites garantit que l’image qui a été testée et validée contient exactement le code, les dépendances (Composer, Yarn/npm) et les assets frontend nécessaires et compilées. Cela élimine le besoin d’exécuter des commandes de build ou d’installation au moment du lancement des conteneurs (sur les serveurs de production ou sur les instances On-Premise). Cela réduit ainsi le risque d’erreurs dues à des dépendances externes ou des configurations de l’environnement hôte. C’est la garantie d’un déploiement « figé » et déterministe. D’ailleurs, comme les assets sont buildées en amont, nous n’avons pas besoin de NodeJS dans le conteneur final, ce qui permet également d’avoir des images plus légères in-fine.

L’objectif est d’avoir des images Docker prêtes pour la production, mais aussi utilisables pour la pré-production et le déploiement on-premise.

Section intitulée l-important-c-est-la-santeL’important, c’est la santé

Dans l’idéal, nous souhaitons également que chaque service définisse son propre healthcheck. Un healthcheck est une commande que Docker pourra exécuter pour déterminer si le conteneur est toujours en bon état. Si ce n’est pas le cas, Docker tentera de redémarrer le conteneur. Voilà un exemple de healthcheck permettant de vérifier si un serveur MySQL est toujours opérationnel dans un conteneur :

mysqladmin ping -h localhost

Il se configure de cette manière dans un docker-compose.yml classique:

services:
  mysql:
    image: "mysql"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
      timeout: 20s
      retries: 10

En général, je préfère que les images « applicatives » déclarent elles-mêmes leur healthcheck plutôt que de laisser l’utilisateur le définir lui-même dans son docker-compose.yml. Cela offre, selon moi, deux avantages :

  • l’image garde la responsabilité de tout configurer comme il faut : l’utilisateur n’a pas besoin de savoir ce qui tourne dans l’image, comment vérifier que tout fonctionne (faut-il utiliser wget, curl ou un autre outil pour avoir l’état du service), etc ;
  • le docker-compose.yml reste le plus simple possible pour l’utilisateur.

Cela se fait grâce à l’instruction HEALTHCHECK directement dans le dockerfile de l’image en question. Voici un exemple d’un healthcheck qu’on pourrait définir dans le Dockerfile pour un conteneur faisant tourner un serveur web :

HEALTHCHECK --interval=5m --timeout=3s CMD curl -f http://localhost/ || exit 1

Info

Certaines images publiques ne fournissent volontairement pas de healthcheck. Il faudra donc le configurer nous-même, soit dans une image à vous, soit dans le docker-compose.yml.

Section intitulée publication-et-utilisation-des-images-finalesPublication et utilisation des images finales

Une fois les images construites, il faut les publier sur un registre Docker pour les mettre à disposition des différents environnements cibles (que ce soit votre serveur de production, dans un Kubernetes, etc). Docker fournit un registre par défaut qui s’appelle Docker Hub. C’est sur ce service que Docker va chercher les images qu’il ne connaît pas encore localement (par exemple depuis un FROM xxx dans un Dockerfile ou depuis un docker-compose.yml). La plupart des systèmes de gestion de code comme GitHub ou GitLab fournissent également un registre de conteneur pour stocker de manière privée vos images Docker. Ces images, dans le cadre d’ArSol, sont stockées dans le registre GitLab du client.

Une fois que tout est prêt, nous obtenons le docker-compose.yml suivant (simplifié dans le cadre de cet article) :

volumes:
  postgres-data: {}
  caddy_data: {}
  caddy_config: {}

services:
  frontend:
    image: "<url du registre>/arsol/frontend:2025.10.23"
    restart: unless-stopped
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
      meilisearch:
        condition: service_healthy
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - caddy_data:/data
      - caddy_config:/config

  postgres:
    image: "<url du registre>/arsol/postgres:2025.10.23"
    restart: unless-stopped
    env_file: .env
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 5s
      retries: 5

  worker-messenger:
    image: "<url du registre>/arsol/worker-messenger:2025.10.23"
    restart: unless-stopped
    env_file: .env
    depends_on:
      frontend:
          condition: service_healthy
      postgres:
        condition: service_healthy

Info

Les volumes pour caddy sont importants pour permettre la génération automatique des certificats SSL.

Vous noterez que nous avons spécifié, pour chaque image, un tag basé sur une date (en l’occurrence 2025.10.23). En effet, quand Docker a déjà récupéré un tag pour une image donnée, il ne cherchera pas à la mettre à jour à moins de nettoyer les images locales (ou à moins de forcer le pull avec l’option docker run --pull=always). Si on utilisait un tag fixe (comme latest par exemple), les images ne seraient jamais mises à jour sur la machine, quand bien même le tag en question aurait changé sur le registre pour cibler une nouvelle version.

Une fois le docker-compose.yml configuré, et le fichier .env créé, nous pouvons lancer docker compose -f docker-compose.yml up -d, et voilà 🎉 : l’infrastructure de production est opérationnelle en HTTP et HTTPS.

La seule différence pour mettre en place une instance OP sera le contenu du .env pour activer/désactiver certaines fonctionnalités spécifiques à ce mode (comme les écrans pour déclencher la synchronisation avec le SaaS, ou la désactivation des données des autres sites archéologiques). Et pour déployer une nouvelle version, il nous faut construire et taguer une nouvelle version de nos images, mettre à jour le docker-compose.yml pour utiliser la bonne version des images et relancer la commande docker compose.

Maintenant que l’objectif est clair, voyons en détails comment y parvenir.

Section intitulée construction-des-imagesConstruction des images

Ici, nous voulons construire toutes les images Docker nécessaires pour faire tourner le projet. À cette étape, nous devons considérer deux cas, suivant si nous utilisons des images publiques telles quelles ou s’il s’agit de nos propres images spécifiques à ArSol.

Section intitulée images-publiquesImages publiques

Dans un premier temps, parlons des images publiques que nous utilisons telles quelles (comme pour Postgres ou Meilisearch). Nous pourrions réutiliser ces images présentes sur le Docker Hub. Mais à la place, nous allons plutôt taguer ces images avec notre système de tag et les pousser sur notre propre registre Docker. Cela apporte plusieurs avantages :

  • toutes les images du projet utilisent le même tag (plus simple pour s’y retrouver) ;
  • le projet n’est dépendant que d’un seul registre Docker, celui du client.

Pour celles-ci, nous allons simplement créer un nouveau tag sur les images en question et les publier sur notre registre Docker privé :

# Tag de l'image
docker image tag <le nom de l'image>:<la version de l'image> <url du registre>/arsol/<le nom de l'image chez nous>:<la version de l'applicatif>

# Publication de l'image sur le registre
docker image push --disable-content-trust <url du registre>/arsol/<le nom de l'image chez nous>:<la version de l'applicatif>

Info

Ici, nous n’allons pas utiliser le système de signature des images, donc nous désactivons la validation des images côté registre en utilisant l’option --disable-content-trust.

Par exemple, pour meilisearch, cela nous donnerait quelque comme cela pour publier le tag « daté » ainsi que le tag latest :

docker image tag getmeili/meilisearch:v1.16 <url du registre>:4567/plancq/arsol/meilisearch:2025.10.23
docker image tag getmeili/meilisearch:v1.16 <url du registre>:4567/plancq/arsol/meilisearch:latest
docker image push --disable-content-trust <url du registre>:4567/plancq/arsol/meilisearch:2025.10.23
docker image push --disable-content-trust <url du registre>:4567/plancq/arsol/meilisearch:latest

Nous pouvons maintenant utiliser cette image directement dans notre docker-compose.yml.

Section intitulée images-applicativesImages applicatives

En revanche, pour les images avec PHP (frontend et worker) contenant le code de l’application, les dépendances & cie, c’est un peu plus compliqué comme nous allons le voir.

Section intitulée la-stack-de-dev-comme-baseLa stack de dev comme base

Nous allons nous servir des images de la stack de dev comme base pour nos images de production. En effet, dans Docker-starter, nos images customs suivent déjà plusieurs bonnes pratiques, notamment pour optimiser leur poids :

  • les instructions RUN sont regroupées le plus possible pour réduire le nombre de couches de l’image (layer squashing) ;
  • un nettoyage immédiat du cache et des fichiers temporaires est fait pour chaque commande afin de diminuer la taille de chaque layer, et donc le poids final de l’image ;
  • le build « multi-stage » est utilisé pour ne pas inclure les outils de dev dans l’image finale (composer, nodejs, yarn, etc) mais uniquement dans une image builder utilisée quand il y a besoin de ces outils.

Pour la production, nous allons appliquer la même logique. Nous créerons donc un Dockerfile unique avec plusieurs stages qui nous donnera les différentes images finales à construire, mais en partant des différentes images de dev pour éviter d’avoir à dupliquer et maintenir une autre installation de PHP dans la bonne version, avec les bonnes extensions, configurer FrankenPHP et caddy, etc.

Voyons à quoi ressemble ce Dockerfile dans les grandes lignes.

Section intitulée le-builder-et-la-preparation-de-l-applicationLe builder et la préparation de l’application

En premier, nous définissons un stage « builder », qui se base sur notre builder de dev et va faire toutes les étapes nécessaires pour installer le projet (installations composer et yarn, construction des assets, etc) :

ARG PROJECT_NAME
FROM ${PROJECT_NAME}-builder AS production-builder

ENV APP_ENV=prod
ENV COMPOSER_MIRROR_PATH_REPOS=1
ENV NODE_ENV=production

# C'est dans ce dossier que nous allons travailler
WORKDIR /var/www

# On récupère les fichiers composer.json et composer.lock et on lance Composer
COPY composer.* /var/www/

RUN composer install \
    --no-dev \
    --prefer-dist \
    --no-scripts \
    --no-interaction \
    && composer clear-cache

# Pareil pour Yarn
COPY package.json yarn.lock .yarnrc.yml /var/www/

RUN yarn install --immutable

# On récupère le reste des fichiers
COPY . /var/www/

# Et enfin, on lance toutes les tasks nécessaires pour avoir l'application opérationnelle 
RUN composer dump-autoload --optimize \
    && yarn run build \
    && bin/console cache:warmup \
    && bin/console assets:install public --relative \
    && rm -rf node_modules

Plusieurs choses sont à noter ici. Déjà, vous remarquez que nous mentionnons un argument de build PROJECT_NAME pour préfixer le nom de l’image de base. Nous verrons dans le prochain article pourquoi c’est nécessaire. Dites vous dans un premier temps que la variable a comme valeur arsol, ce qui correspond au nom du projet Docker compose de la stack de dev en local.

Info

J’ai voulu dissocier le Dockerfile de la stack de dev du Dockerfile de la stack de prod, d’où la nécessité de pouvoir référencer l’image de la stack de dev. Si nous avions mergé les deux Dockerfile, nous n’aurions pas eu besoin de cette variable PROJECT_NAME.

Ensuite, vous vous demandez peut-être la raison pour laquelle nous effectuons l’installation de Composer et Yarn avant de COPY le code et l’ensemble des fichiers de l’application. Cette approche permet de tirer parti du cache de couches : Docker commence la construction en vérifiant le cache pour la première instruction et, en cas de correspondance, il réutilise la couche et passe à l’instruction suivante. Mais si une instruction invalide le cache (par exemple, un COPY avec un fichier modifié), toutes les instructions suivantes seront exécutées sans utiliser le cache, ce qui ralentit la construction. Étant donné que les dépendances sont moins sujettes à changement que le code, il est plus probable que les étapes d’installation de Composer et Yarn soient réutilisées depuis le cache, car elles précèdent l’opération de COPY du code.

On peut maintenant préparer le stage docker qui contiendra uniquement php et les outils nécessaires pour le runtime (FrankenPHP, Caddy, etc), en se basant sur l’image du conteneur frontend :

ARG PROJECT_NAME
FROM ${PROJECT_NAME}-frontend AS production-php-base

WORKDIR /var/www

# Symfony tournera dans son env prod
ENV APP_ENV=prod

# On récupère le code de l'application, ses dépendances et assets depuis le stage builder vu précédemment
COPY --from=production-builder /var/www /var/www

Il faut bien faire attention à ce qui est inclus dans les conteneurs. C’est pour cette raison que nous allons créer un Dockerfile.gitignore au même niveau que le Dockerfile du projet. On retire tout ce qui n’est pas nécessaire, comme les dépendances ou les assets qui auraient été installées/construites dans votre stack de dev :

.castor/
.castor.stub.php
.env.local
.env.ci
*.cache
.git/
.idea/
.home/
.yarn/
doc/
infrastructure/
!infrastructure/docker/services/php-production/
tests/
tools/
node_modules/
var/
vendor/
public/assets/
public/build/
public/bundles/
public/media/
!public/media/.gitkeep

Info

Après réflexion, on aurait peut-être mieux fait de tout bloquer par défaut et ne lister que ce qu’il fallait garder (src, config, public, etc). 😝

Section intitulée frontend-et-workerFrontend et worker

Maintenant que nous avons un stage avec l’application opérationnelle, nous allons pouvoir construire les images finales pour notre projet. Voilà l’image qui se base sur le stage construit ci-dessus :

FROM production-php-base AS production-frontend

# Mise en place d'un entrypoint qui s'occupera d'initialiser l'application
COPY infrastructure/docker/services/php-production/entrypoint-frontend.sh /entrypoint.sh

# Configuration de Caddy
COPY infrastructure/docker/services/php-production/etc/. /etc/

RUN ["chmod", "+x", "/entrypoint.sh"]
ENTRYPOINT ["/entrypoint.sh"]

# Indique les ports utilisés par ce conteneur 
EXPOSE 80
EXPOSE 443

# Commande qui sera exécutée dans le conteneur
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

Nous avons défini un entrypoint pour ce conteneur. Lors du démarrage de ce dernier, il permet d’initialiser toute la partie data (schéma SQL, migrations Doctrine à jouer, configuration du transport pour Messenger, indexation des données dans Meilisearch). Cela est rendu possible car nous n’aurons toujours qu’une seule instance de ce conteneur en production (aucun pic de trafic à gérer). Si ce n’est pas votre cas, il faudra peut-être adapter cette technique.

Voilà à quoi ressemble cet entrypoint :

#!/bin/bash
set -e

echo "Updating all databases..."

php bin/console doctrine:database:create --if-not-exists --env=prod --no-interaction
php bin/console doctrine:migrations:sync-metadata-storage --env=prod --no-interaction
php bin/console doctrine:migrations:migrate --env=prod --no-interaction
php bin/console messenger:setup-transports --env=prod --no-interaction
php bin/console app:meilisearch:index --env=prod

echo "Ready to start the frontend service."

exec "$@"

Enfin, il ne nous manque plus que le stage qui permettra de construire l’image faisant tourner le conteneur pour le worker Messenger :

FROM production-php-base AS production-worker-messenger

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        procps \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*

CMD ["php", "-d", "memory_limit=-1", "bin/console", "messenger:consume", "-vv", "async"]

Ici, on note l’installation de procps qui fournit le binaire pgrep, utile pour vérifier si le processus qui fait tourner le worker est toujours actif. Il sera employé dans le healthcheck de ce service :

services:
  worker-messenger:
    # ...
    healthcheck:
      test: ["CMD-SHELL", "pgrep -f \"messenger:consume\" || exit 1"]
      interval: 5s
      timeout: 5s
      retries: 5

Grâce au multi-stage de Docker, nous avons ainsi pu construire toutes nos images utilisant PHP dans un même Dockerfile.

Section intitulée a-tableA table !

Maintenant que nos images peuvent être construites, il va nous falloir les taguer puis les envoyer sur le registre. On pourrait lancer les mêmes commandes docker image tag|push vues précédemment. Mais à la place, on va simplifier et automatiser le processus grâce à Bake.

Cet outil permet de définir l’ensemble des cibles de build dans un fichier de configuration (bake.hcl par exemple) et de les construire/pousser en une seule commande. Voici un extrait de ce à quoi ressemble notre fichier bake :

group "default" {
  targets = [
    "frontend",
    "worker-messenger",
  ]
}

target "frontend" {
  context    = "."
  dockerfile = "./infrastructure/docker/services/php-production/Dockerfile"
  target     = "production-frontend"
}

target "worker-messenger" {
  context    = "."
  dockerfile = "./infrastructure/docker/services/php-production/Dockerfile"
  target     = "production-worker-messenger"
}

Avec ce fichier, il ne nous reste qu’à lancer la commande suivant pour construire les images, les tagger et les envoyer au registre docker :

docker buildx bake --file bake.hcl --push

Info

Malheureusement, Bake n’a pas l’air de permettre de juste taguer des images existantes, donc nous ne pourrons pas l’utiliser pour remplacer les docker image tag|push des images publiques.

Nos images sont maintenant disponibles sur le registre du client :

Les images disponibles sur le registre GitLab

Section intitulée conclusionConclusion

Nous avons vu dans cet article une bonne partie des étapes qui nous permettent de construire les images Docker dont nous aurons besoin pour faire tourner l’application dans tous nos environnements.

Je n’en ai pas parlé jusque-là mais il reste quelques points de sécurité à garder en tête avant de mettre en production cette infrastructure (comme les capabilities Docker ou encore les logs générés par Docker Compose qui peuvent vite remplir le disque par défaut en cas de fort trafic).

Le client devant être autonome pour déployer les prochaines évolutions de l’application, nous ne nous sommes pas arrêtés là. Nous avons donc mis en place tout un ensemble de task Castor permettant d’automatiser la création et la publication des images, ainsi que de simplifier le pilotage de la stack dans les différents environnements. Mais nous verrons tout cela dans le prochain article.

Commentaires et discussions

Ces clients ont profité de notre expertise