Uzinasit : retour d'expérience sur mon usine à site Drupal 11

sites.php

J'ai profité d'avoir un peu de temps durant l'été 2025 pour commencer à rationaliser l'ensemble de mes sites histoire de m'en simplifier la gestion

Pourquoi une site factory ?

Au fil des années, j'ai accumulé des sites Drupal mais aussi d'autres CMS (Wordpress, Joomla), certains pour moi (celui-là, des outils métiers comme mon dashboard de projets, des sideprojects comme mon site de photos de drone.) Mais aussi des sites pour des activités pro d'amis ou membres de la famille. 

Le problème quand on a douze sites indépendants, c'est que la maintenance devient compliquée et chronophage... Certains joomla n'étaient plus du tout à jour, plus upgradable, idem pour des wordpress... j'avais encore des drupal 8... un beau bazar.

J'ai donc construit Uzinasit, ma propre "Usine à site" Drupal : une seule base de code, un seul composer.json, plusieurs bases de données, des configs Drupal isolées par site. Du multi-site Drupal natif, mais industrialisé avec mes propres outils autour (Lando pour le dev local, GitLab CI pour le déploiement, une commande Drush maison pour scaffolder un nouveau site en deux minutes).

Aujourd'hui, ajouter un site se résume à drush uzc. Mettre à jour Drupal core sur les douze sites se fait en un composer update et un git push. C'est de cette mécanique dont je veux parler dans cet article.

Au programme :

  1. La vue d'ensemble de l'architecture
  2. Comment une seule codebase Drupal sert douze sites
  3. L'environnement de dev local avec Lando
  4. Les scripts spécifiques qui me font gagner un temps fou
  5. La commande drush uzc pour ajouter un site
  6. Le déploiement via GitLab CI (matrix sur les sites)
  7. Le workflow de mise à jour Drupal (le vrai gain)

1. Vue d'ensemble de l'architecture

Avant de plonger, un schéma simplifié de l'infra :

┌─ Un repo Git (uzinasit) ─────────────────────────────┐
│                                                       │
│  web/                                                 │
│   ├── core/  + contribs (gérés par Composer)          │
│   ├── modules/<site>/   (1 module custom par site)    │
│   ├── themes/<site>/    (thèmes propres à chaque site)│
│   └── sites/                                          │
│        ├── sites.php          (hostname → dossier)    │
│        ├── settings-shared.php  (le cœur !)           │
│        ├── kgaut/settings.php                         │
│        ├── dashboard/settings.php                     │
│        ├── drone/settings.php                         │
│        └── …                                          │
│                                                       │
│  scripts/        (db-sync, deploy-code, …)            │
│  drush/sites/    (alias Drush par site)               │
│  .lando.yml      (stack Lando dev local)              │
│  .gitlab-ci.yml  (pipeline CI/CD)                     │
└───────────────────────────────────────────────────────┘
              │                          │
              ▼                          ▼
        ┌────────────┐            ┌────────────┐
        │   LANDO    │            │    PROD    │
        │ 12 DBs     │            │ 12 DBs     │
        │ Redis      │            │ Redis      │
        │ Mailpit    │            │ Sentry     │
        │ PMA        │            │ Backups    │
        └────────────┘            └────────────┘

Les briques principales :

  • Drupal 11 en mode multi-site natif (web/sites/<dossier>/).
  • Composer pour gérer le code : un seul composer.json, un seul composer.lock, donc une version unique de Drupal et de chaque contrib pour tous les sites.
  • Lando (surcouche de docker) pour le dev local : 1 service appserver, 12 services MariaDB, du Redis, du Mailpit, du PhpMyAdmin.
  • GitLab CI pour le déploiement, avec une matrix qui parallélise les jobs sur tous les sites.
  • Drush + des alias par site, pour cibler @kgaut, @dashboard, @drone… aussi bien en local qu'en prod.
  • Sentry pour le monitoring (un projet par site).

2. L'infra Drupal - comment une même codebase sert une douzaine de sites

Drupal supporte le multi-site nativement depuis très longtemps. Le mécanisme est simple : selon l'URL de la requête, Drupal cherche un dossier web/sites/<quelque-chose>/ contenant un settings.php, et utilise les paramètres de ce fichier (base de données, fichiers, config…) pour cette requête. Toute la magie d'Uzinasit consiste à rendre ce mécanisme industrialisable.

2.1 sites.php - router les hostnames vers les dossiers

Premier fichier clé, web/sites/sites.php. C'est lui qui mappe les URL aux dossiers de sites :

<?php

$sites['kgaut.net'] = 'kgaut'; // URL PROD
$sites['kgaut.lndo.site'] = 'kgaut'; // URL LOCALE

$sites['drone.kgaut.net'] = 'drone';
$sites['drone.lndo.site'] = 'drone';

// … etc, une entrée par environnement, par site

Pour chaque site j'ai au minimum deux entrées (URL prod + URL locale .lndo.site), parfois trois (URL preprod). Tout cela pointe vers le même dossier Drupal, qui contient les paramètres spécifiques au site.

2.2 Le fichier settings.php minimaliste

Tous les settings.php de tous les sites font exactement la même chose. Quatre lignes utiles, pas une de plus. Exemple web/sites/kgaut/settings.php :

<?php

$siteFolder = 'kgaut';
$siteFolderUp = strtoupper($siteFolder);
require $app_root . '/sites/settings-shared.php';
if (file_exists($app_root . '/' . $site_path . '/settings.local.php')) {
  include $app_root . '/' . $site_path . '/settings.local.php';
}

C'est tout. Pour le site dashboard, c'est strictement identique, sauf que $siteFolder = 'dashboard'. Toute la configuration réelle est centralisée dans settings-shared.php. C'est ce fichier qui fait le vrai boulot.

2.3 settings-shared.php - le cœur de l'usine

Ce fichier est inclus par les douze settings.php, et il configure dynamiquement Drupal en fonction du $siteFolder qui lui est passé. Voici les parties intéressantes (La partie classique du settings.php n'est pas affichée ici, mais présente dans le fichier) :

Connexion DB à partir d'env vars :

$databases['default']['default'] = [
  'database'         => getenv($siteFolderUp . '_DB'),
  'username'         => getenv($siteFolderUp . '_USER'),
  'password'         => getenv($siteFolderUp . '_PWD'),
  'host'             => getenv($siteFolderUp . '_HOST'),
  'port'             => '3306',
  'isolation_level'  => 'READ COMMITTED',
  'driver'           => 'mysql',
  'namespace'        => 'Drupal\\mysql\\Driver\\Database\\mysql',
  'autoload'         => 'core/modules/mysql/src/Driver/Database/mysql/',
];

Pour le site kgaut, récupération des variables d’environnement KGAUT_DB, KGAUT_USER, KGAUT_PWD, KGAUT_HOST. Pour dashboard, c'est DASHBOARD_DB, etc. Un nouveau site : on ajoute 4 lignes dans .env.

Fichiers privés, tmp, config sync, isolés par site :

$settings['file_private_path']     = '../files/' . $siteFolder . '/private';
$settings['file_temp_path']        = '../files/' . $siteFolder . '/tmp';
$settings['config_sync_directory'] = '../files/' . $siteFolder . '/config/sync';

Chaque site a ses propres dossiers files/<site>/{private,tmp,config,dumps}. Aucun risque qu'un site écrive dans les fichiers d'un autre.

Redis optionnel, activé par site :

$redisKey = $siteFolderUp . '_REDIS_ENABLED';
if (getenv($redisKey) && (bool) getenv($redisKey) === TRUE) {
  $settings['redis.connection']['host']      = getenv('REDIS_HOST');
  $settings['redis.connection']['port']      = getenv('REDIS_PORT');
  $settings['redis.connection']['interface'] = getenv('REDIS_INTERFACE');
  $settings['container_yamls'][]             = DRUPAL_ROOT . '/sites/redis.services.yml';
  $settings['cache']['default']              = 'cache.backend.redis';
  $settings['cache_prefix']['default']       = $siteFolder . '_';
}

Le préfixe de cache est dérivé de $siteFolder, donc deux sites peuvent partager la même instance Redis sans collision. Les sites qui en ont besoin (typiquement le dashboard, très sollicité) activent Redis via DASHBOARD_REDIS_ENABLED=true dans le .env. Les petits sites qui n'en ont pas besoin tournent sur cache base de données classique.

Sentry par site :

if (getenv($siteFolderUp . '_SENTRY_DSN')) {
  $_SERVER['SENTRY_DSN'] = getenv($siteFolderUp . '_SENTRY_DSN');
}

Chaque site a son propre projet Sentry. Quand kgaut.net lève une exception, je la vois remonter dans le projet Sentry kgaut, pas dans celui du dashboard.

Indicateur d'environnement :

if (getenv('APP_ENV')) {
  $environements = [
    'dev'     => ['name' => 'dev',     'bg_color' => '#006600', 'fg_color' => '#ffffff'],
    'preprod' => ['name' => 'Staging', 'bg_color' => '#FF9900', 'fg_color' => '#000000'],
    'prod'    => ['name' => 'Prod',    'bg_color' => '#ef5350', 'fg_color' => '#000000'],
  ];
  if (isset($environements[getenv('APP_ENV')])) {
    $config['environment_indicator.indicator'] = $environements[getenv('APP_ENV')];
  }
}

Combiné avec le module environment_indicator, ça m'affiche un bandeau vert en dev, orange en preprod, rouge en prod dans l'admin Drupal. Basique, mais ça évite les erreurs du « oups je viens de faire la modif en prod au lieu du local ».

Config split par environnement :

$config["config_split.config_split.".getenv("APP_ENV")]["status"] = TRUE;

J'ai des splits config_split.dev, config_split.preprod, config_split.prod qui activent ou désactivent automatiquement des modules selon l'env (par exemple devel en dev seulement).

Et en dev seulement, on charge les services de développement et on route les mails vers Mailpit :

if (getenv('APP_ENV') && getenv('APP_ENV') === 'dev') {
  $settings['container_yamls'][] = DRUPAL_ROOT . '/sites/development.yml';
  $config['system.logging']['error_level']                = 'verbose';
  $config['symfony_mailer.settings']['default_transport'] = 'mailpit';
}

Tout ça est dans un seul fichier, qui sert les douze sites. Quand j'ajoute une nouvelle fonctionnalité de configuration partagée (ex: ajouter un Sentry, routage des mails), il bénéficie automatiquement à tous les sites.

2.4 Le .env, la config par site

APP_ENV='dev'

KGAUT_USER="drupal11"
KGAUT_PWD="drupal11"
KGAUT_HOST="database_kgaut"
KGAUT_DB="kgaut"
KGAUT_SENTRY_DSN="https://…@sentry.io/…"

DASHBOARD_USER="drupal11"
DASHBOARD_PWD="drupal11"
DASHBOARD_HOST="database_dashboard"
DASHBOARD_DB="dashboard"
DASHBOARD_REDIS_ENABLED=true
DASHBOARD_SENTRY_DSN="https://…@sentry.io/…"

REDIS_HOST=redis
REDIS_PORT=6379
REDIS_INTERFACE=PhpRedis

En dev, tous les sites partagent le même utilisateur MySQL drupal11/drupal11. En prod, j'ai un utilisateur MySQL distinct par base, avec un mot de passe distinct, pour une isolation plus forte.

2.5 Les alias Drush - pouvoir cibler chaque site en CLI

Pour cibler un site en ligne de commande, Drush utilise des alias. J'ai un fichier drush/sites/<site>.site.yml par site, par exemple drush/sites/kgaut.site.yml :

dev:
  root: /app
  uri: https://kgaut.lndo.site

2.6 Modules custom - un par site, un mutualisé

L'organisation des modules est claire :

  • web/modules/<site>/ : module propre à chaque site, contient le code métier spécifique (par exemple web/modules/dashboard/ contient le code de mon site de suivi de projet, web/modules/dronestagram/ contient mon module pour mon site de photos drones).

Côté thèmes, même logique, chaque site a son thème.


3. L'infra Lando pour le dev local

Pour faire tourner les douze sites en local, j'utilise Lando. C'est une surcouche sur Docker qui simplifie énormément la déclaration de stacks de dev, avec une configuration YAML lisible et des « recipes » toutes faites (Drupal 11 inclus).

L'alternative serait un docker-compose.yml à la main. Lando me donne en plus :

  • Un proxy automatique qui résout les domaines .lndo.site en HTTPS valide (certificat auto-signé installé dans le store du système)
  • Une notion de « tooling » qui définit des commandes haut niveau (lando drush, lando phpstan…)

3.1 Le .lando.yml - la stack complète

Voici le squelette de mon .lando.yml (j'ai raccourci la partie services DB) :

name: uzinasit
recipe: drupal11
config:
  webroot: web

proxy:
  appserver:
    - kgaut.lndo.site
    - dashboard.lndo.site
    - drone.lndo.site
    ...
  mailpit:
    - 'mail.lndo.site:8025'
  pma:
    - pma.lndo.site

services:

  node:
    type: 'node:22'
    ssl: true
    sslExpose: true
    portforward: 4444

  mailpit:
    api: 3
    type: lando
    services:
      image: axllent/mailpit
      ports: [8025, 1025]
      environment:
        MP_MAX_MESSAGES: 5000
        MP_SMTP_AUTH_ACCEPT_ANY: 1

  cache:
    type: redis
    persist: true
  redis:
    type: 'redis:5'

  pma:
    type: phpmyadmin
    hosts:
      - database_kgaut
      - database_dashboard
      - database_drone
      # … les 12

  database_kgaut:
    type: 'mariadb:10.11'
    portforward: true
    creds: { user: drupal11, password: drupal11, database: kgaut }
  database_dashboard:
    type: 'mariadb:10.11'
    portforward: true
    creds: { user: drupal11, password: drupal11, database: dashboard }
  # … etc, une entrée par site

Les points à noter :

  • Node est un service à part, utilisé pour les builds front (SASS, gulp...)
  • Mailpit capture tous les mails envoyés en dev — visible sur mail.lndo.site:8025.
  • Une base MariaDB 10.11 par site
  • PhpMyAdmin centralise l'accès aux douze bases sur pma.lndo.site.

3.2 Pourquoi douze bases différentes plutôt qu'une seule avec préfixes ?

C'est un choix d'architecture. Drupal sait travailler avec des préfixes de tables (prefix dans $databases), donc on pourrait mutualiser une seule instance MariaDB. Mais j'ai choisi une instance par site, pour ces raisons :

  1. Cohérence avec la prod : en prod chaque site est sur sa propre base, avec son propre utilisateur MySQL. Faire pareil en local évite les surprises.
  2. Isolation totale : drop database kgaut; ne touchera jamais le dashboard.
  3. Dumps simples : un fichier .sql.gz par site, qu'on importe sans réfléchir à des préfixes. Le même dump fonctionne en local et en prod.
  4. Performance : pas de mutualisation, donc pas de contention. La pause d'un dump n'impacte que le site concerné.

3.3 Démarrer la stack

Une fois .env rempli :

lando start

Et voilà, douze Drupal accessibles en HTTPS sur https://kgaut.lndo.site, https://dashboard.lndo.site, etc. Ajouter un nouveau site ne demandera pas de toucher à cette config — la commande drush uzc (qu'on voit plus loin) le fera automatiquement.


4. Les scripts spécifiques - le vrai gain de productivité quotidien

Lando offre une section tooling qui permet de définir des commandes haut niveau. Toutes les commandes du tableau ci-dessous se lancent en lando <commande>, depuis la racine du projet :

Commande LandoCe qu'elle fait
lando front:buildnpm i + build des thèmes (je pourrais isoler, mais j'ai choisi la simplicité
lando db-export-allDump SQL horodaté de chaque base, dans files/<site>/dumps/
lando drush-allExécute la même commande Drush sur les 12 sites, séquentiellement
lando db-get-allRapatrie le dernier dump prod de tous les sites via SSH
lando db-import-allImporte localement le dernier dump .sql.gz de tous les sites
lando db-sync <site>db-get + db-import + drush deploy enchaînés pour un seul site
lando db-sync-allIdem mais pour tous les sites détectés
lando phpstanAnalyse PHPStan sur les modules custom
lando phpcsAnalyse PHPCS
lando rectorRector (refactoring automatique guidé)
lando phpunitPHPUnit
lando grumphpSuite complète CS + PHPStan + composer check (utilisé en hook pre-commit)

4.1 db-sync - Rapatriement de la base de prod en local

Un simple :

lando db-sync dashboard

Et 30 secondes plus tard, ma base locale dashboard contient le dernier dump prod, et le drush deploy a tourné. Je suis aligné à la prod, prêt à développer.

Le script scripts/db-sync.sh est volontairement court — il orchestre deux scripts plus simples :

#!/usr/bin/env bash
set -euo pipefail

SITE="${1:-}"
DRY_RUN=false
NO_DEPLOY=false

# parse des flags --dry-run et --no-deploy
for arg in "${@:2}"; do
  case "$arg" in
    --dry-run)   DRY_RUN=true ;;
    --no-deploy) NO_DEPLOY=true ;;
  esac
done

GET_SCRIPT="./scripts/db-get.sh"
IMPORT_SCRIPT="./scripts/db-import.sh"

echo "--- Récupération du dump ---"
"$GET_SCRIPT" "$SITE" || echo "Pas de nouveau dump distant, on utilise le local."

echo "--- Import du dump ---"
IMPORT_ARGS=("$SITE")
[[ "$NO_DEPLOY" == true ]] && IMPORT_ARGS+=(--no-deploy)
"$IMPORT_SCRIPT" "${IMPORT_ARGS[@]}"

Les deux briques qu'il chaîne :

db-get.sh se connecte en SSH à mon serveur, cherche le dump .sql.gz le plus récent dans le dossier files/<site>/dumps/ distant, et le rapatrie en local via rsync (avec fallback scp si rsync absent) :

REMOTE_BASE="/home/uzinasit/public_html/files/${SITE}/dumps"
LOCAL_BASE="./files/${SITE}/dumps"

LATEST_REMOTE_FILE=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
  "ls -1t ${REMOTE_BASE}/*.sql.gz 2>/dev/null | head -n1")

rsync -avz --progress \
  "${REMOTE_USER}@${REMOTE_HOST}:${LATEST_REMOTE_FILE}" \
  "${LOCAL_BASE}/"

db-import.sh importe le .sql.gz directement via mysql sans extraction intermédiaire (zcat | mysql), puis lance drush deploy :

MYSQL_CMD=$(drush "@$SITE" sql:connect 2>/dev/null)
zcat "$LATEST_FILE" | eval "${MYSQL_CMD} --skip-ssl"

echo "Dump importé pour $SITE"
if [[ "$NO_DEPLOY" == true ]]; then
  echo "Drush deploy ignoré (--no-deploy)"
else
  drush "@$SITE" deploy
fi

Le drush deploy est important : il importe la config Drupal locale (config:import), passe les hooks update (updatedb), reconstruit le cache. Après ça, le site local correspond exactement à l'état du code courant + les données de prod.

Les flags --dry-run (vérifier sans rien faire) et --no-deploy (importer sans lancer drush deploy) sont là pour les cas particuliers, typiquement, quand je veux la base prod mais sans réimporter le config split « dev ».

4.2 db-sync-all - Le gros reset

Si je reviens d'une semaine de vacances et que je veux remettre à plat toute mon usine locale, je lance :

lando db-sync-all

Le script découvre tout seul la liste des sites en scannant drush/sites/*.site.yml, et appelle db-sync.sh pour chacun :

mapfile -t ALIAS_FILES < <(printf '%s\n' "${DRUSH_SITES_DIR}"/*.site.yml | sort)

SITES=()
for f in "${ALIAS_FILES[@]}"; do
  alias_name="${$(basename -- "$f")%.site.yml}"
  case "$alias_name" in
    self|uzinasit|mbc) continue ;;  # skip les techniques
    *) SITES+=("$alias_name") ;;
  esac
done

echo "Sites détectés (${#SITES[@]}): ${SITES[*]}"

for site in "${SITES[@]}"; do
  if "$SYNC_ONE_SCRIPT" "$site" "${EXTRA_ARGS[@]}"; then
    SUCCESS=$((SUCCESS + 1))
  else
    FAILED=$((FAILED + 1))
  fi
done

echo "Résumé sync-all: ${SUCCESS} succès, ${FAILED} échec(s)."

Quand j'ajoute un nouveau site avec drush uzc, son alias drush/sites/<site>.site.yml est créé automatiquement → il est inclus automatiquement au prochain db-sync-all. Aucune liste à maintenir nulle part.


5. Ajouter un nouveau site avec drush uzc

Ajouter un nouveau site dans Uzinasit demande sept modifications de fichiers dans le bon ordre :

  1. web/sites/sites.php (mapping hostname → dossier)
  2. drush/sites/<alias>.site.yml (alias Drush)
  3. .lando.yml (service MariaDB, proxy, PMA, tooling…)
  4. .env (4 variables)
  5. files/<alias>/ (création des dossiers)
  6. web/sites/<alias>/ (settings.php + services.yml)
  7. .gitlab-ci.yml (ajout dans la matrix)

J'ai fait ça a la main quelques fois, et j'en ai eu marre, oubli, perte de temps...

J'ai donc écrit une commande Drush qui fait tout ça automatiquement. Elle vit dans drush/Commands/uzinasit/CreateCommand.php et s'appelle drush uzinasit:create, alias drush uzc.

Le déroulé interactif :

$ drush uzc
Enter the site alias (e.g., ulm) [ulm]: monnouveausite
Enter the local URL (must end with .lndo.site) [monnouveausite.lndo.site]:
Enter the production URL (without the procol) [monnouveausite.uzinasit.kgaut.net]: monnouveausite.kgaut.net

 Site Information
 ────────────────
 ┌────────────────┬───────────────────────────────────┐
 │ Parameter      │ Value                             │
 ├────────────────┼───────────────────────────────────┤
 │ Site Alias     │ monnouveausite                    │
 │ Local URL      │ monnouveausite.lndo.site          │
 │ Production URL │ monnouveausite.kgaut.net          │
 └────────────────┴───────────────────────────────────┘

 Do you want to create this site? (yes/no) [yes]:

Updating sites.php... ✅ sites.php updated.
Creating Drush alias file... ✅ Drush alias file created.
Updating .lando.yml... ✅ .lando.yml updated.
Updating .env file... ✅ .env file updated.
Creating site files directory... ✅ Site files directory created.
Creating site configuration directory... ✅ Site configuration directory created and settings.php updated.
Updating .gitlab-ci.yml... ✅ .gitlab-ci.yml updated with new site alias.

[OK] Site 'monnouveausite' has been successfully created!

Sous le capot, la commande utilise Symfony\Component\Yaml\Yaml pour lire/écrire .lando.yml et .gitlab-ci.yml proprement. Voici l'extrait qui modifie le .lando.yml pour ajouter le service MariaDB et le tooling associé :

$landoYml = Yaml::parseFile($landoYmlPath);

// Ajoute l'URL locale au proxy appserver
$landoYml['proxy']['appserver'][] = $localUrl;

// Ajoute un nouveau service MariaDB pour ce site
$dbServiceName = "database_$alias";
$landoYml['services'][$dbServiceName] = [
  'type'        => 'mariadb:10.11',
  'portforward' => true,
  'creds'       => [
    'user'     => 'drupal11',
    'password' => 'drupal11',
    'database' => $alias,
  ],
];

// Inscrit la nouvelle base dans PhpMyAdmin
$landoYml['services']['pma']['hosts'][] = $dbServiceName;

// Ajoute le site dans tous les tooling concernés
$landoYml['tooling']['db-export-all']['cmd'][] = [
  $dbServiceName => "/helpers/sql-export.sh /app/files/$alias/dumps/`date +%Y-%m-%d_%H-%M-%S`-$alias.sql"
];
$landoYml['tooling']['drush-all']['cmd'][]     = ["drush @$alias"];
$landoYml['tooling']['db-get-all']['cmd'][]    = "./scripts/db-get.sh $alias";
$landoYml['tooling']['db-import-all']['cmd'][] = "./scripts/db-import.sh $alias";

file_put_contents($landoYmlPath, Yaml::dump($landoYml, 10, 2));

Et la mise à jour de la matrix GitLab CI utilise une regex sur la liste des sites :

if (preg_match('/- SITE: \[(.*?)\]/', $content, $matches)) {
  $sitesList = $matches[1];
  $sites = array_map('trim', explode(',', $sitesList));

  if (!in_array("'$alias'", $sites)) {
    $sites[] = "'$alias'";
    $newSitesList = implode(', ', $sites);
    $newContent = preg_replace('/- SITE: \[(.*?)\]/', "- SITE: [$newSitesList]", $content);
    file_put_contents($gitlabCiPath, $newContent);
  }
}

Pour les deux dossiers à créer (files/<alias>/ et web/sites/<alias>/), la commande copie des templates de scaffolding depuis drush/Commands/uzinasit/scaffolding/ :

$sourceDir = dirname(__FILE__) . "/scaffolding/sites_SITE";
$targetDir = "$projectRoot/web/sites/$alias";
$fs->mirror($sourceDir, $targetDir);

// Remplace le placeholder dans le settings.php scaffoldé
$content = file_get_contents("$targetDir/settings.php");
$content = str_replace("\$siteFolder = 'ulm';", "\$siteFolder = '$alias';", $content);
file_put_contents("$targetDir/settings.php", $content);

Une fois la commande terminée, il ne me reste qu'à faire :

lando restart                    # pour prendre en compte la nouvelle DB

Et voilà. Site ajouté en deux minutes.


6. Le déploiement - GitLab CI et la matrice sur les sites

Tout le code vit dans un repo GitLab (j'auto-héberge mon GitLab, mais cela fonctionnerait pareil sur gitlab.com). Le déploiement est piloté par .gitlab-ci.yml, qui s'appuie sur mes propres templates partagés entre plusieurs projets : kgaut/gitlab-ci-templates.

6.1 La structure du pipeline

variables:
  CI_TEMPLATE_VERSION: &CI_TEMPLATE_VERSION 0.4.21
  QA_DOCKER_IMAGE: wodby/drupal-php:8.3-dev
  DAYS_DUMP_TO_KEEP: 7
  SENTRY_ORG: 'kgautnet'

include:
  - project: kgaut/gitlab-ci-templates
    ref: *CI_TEMPLATE_VERSION
    file:
      - '/templates/generic/stages-variables-extends.yml'
      - '/templates/drupal/backup.yml'
      - '/templates/drupal/deploy.yml'
      - '/templates/generic/deploy-tracking.yml'
      - '/templates/qa/qa.yml'
      - '/templates/qa/phpstan.yml'
      - '/templates/qa/phpcs.yml'
      - '/templates/qa/phpunit.yml'

Mes templates externalisés gèrent tout le boilerplate (SSH, stages, conventions de nommage). Le pipeline a 5 stages : QA → predeploy → deploy → postdeploy → scheduled.

6.2 La matrix - les jobs en parallèle, par site

C'est le mécanisme central qui permet de paralléliser sur les sites :

.drush-aliases:
  parallel:
    matrix:
      - SITE: ['kgaut', 'dashboard', ...]

.sentry-sites:
  parallel:
    matrix:
      - SITE: ['kgaut', 'dashboard', ...]

Tout job qui extends: .drush-aliases est automatiquement lancé une fois par site, en parallèle, avec la variable $SITE qui prend chaque valeur. Donc prod:deploy-config, prod:clear-cache, prod:postdeploy:backup, prod:scheduled-backup — toutes ces étapes tournent en parallèle sur 11 jobs (un par site).

Et quand j'ajoute un nouveau site avec drush uzc, il est automatiquement ajouté à cette matrix (la commande Drush modifie le .gitlab-ci.yml toute seule, on l'a vu juste avant).

6.3 Les jobs clés

QA (stage QA) — tourne sur chaque merge request et push sur main :

  • phpstan : analyse statique sur les modules custom
  • phpcs : coding standards Drupal
  • phpunit : tests unitaires

assets:generation (stage predeploy) compile les CSS et JS sur runner Node 22 :

assets:generation:
  image: node:22
  stage: predeploy
  script:
    - npm i
    - npm run sass-build-kgaut
    - npm run sass-build-wam
    - npm run sass-build-daash
    - npm i --prefix ./web/libraries/
    - npm i --prefix ./web/themes/custom/yfdev
    - npm run build --prefix ./web/themes/custom/yfdev
    - ...
  artifacts:
    paths:
      - web/themes/kgaut_2024/css
      - web/themes/daash/css
      - web/themes/wam_theme/css
      - web/themes/custom/yfdev/dist
      - web/libraries/node_modules
      - ...
    expire_in: 20 minutes

Les CSS générés sont publiés en artefacts (durée de vie 20 min, juste assez pour le job suivant).

assets:deploy (stage deploy) pousse les assets compilés en SSH avec rsync :

assets:deploy:
  image: alpine
  stage: deploy
  before_script:
    - apk update && apk add openssh git curl rsync
  script:
    - rsync -avhzi --delete --stats web/themes/kgaut_2024/css \
        -e "ssh -p $SSH_PORT" $SSH_USER@$SSH_HOST:$PROJECT_ROOT/web/themes/kgaut_2024
    - rsync -avhzi --delete --stats web/themes/daash/css \
        -e "ssh -p $SSH_PORT" $SSH_USER@$SSH_HOST:$PROJECT_ROOT/web/themes/daash
    # … etc
  needs: [assets:generation, prod:deploy]

prod:deploy (stage deploy) c'est ici qu'on déploie le code PHP. Ce job SSH dans la prod et exécute scripts/deploy-code.sh :

.deploy:
  stage: deploy
  extends: [.ssh]
  script:
    - $SSH_CHAIN 'bash -s' < ./scripts/deploy-code.sh \
        $PROJECT_ROOT $DRUPAL_SITE_PATH $DRUSH_BIN \
        $CI_ENVIRONMENT_NAME $CI_PIPELINE_IID $CI_COMMIT_REF_NAME

Le script deploy-code.sh est volontairement minimaliste :

#!/bin/bash
set -e

PROJECT_ROOT="$1"
CI_ENVIRONMENT_NAME="$4"
CI_PIPELINE_IID="$5"
CI_COMMIT_REF_NAME="$6"

cd "$PROJECT_ROOT"

# Passer les dossiers settings en écriture pendant la mise à jour
chmod +w "$PROJECT_ROOT/web/sites/default"
chmod +w "$PROJECT_ROOT/web/sites/default/settings.php"
chmod +w "$PROJECT_ROOT/web/sites/monboncoin"
chmod +w "$PROJECT_ROOT/web/sites/monboncoin/settings.php"
# … pour chaque site

if [ $CI_ENVIRONMENT_NAME = "prod" ]; then
  TAG="$6"
  git fetch --tags
  git checkout "$TAG"
else
  git fetch --all
  git reset --hard origin/$CI_COMMIT_REF_NAME
fi

composer install --no-dev

# Restaurer les perms read-only
chmod ugo-w "$PROJECT_ROOT/web/sites/default"
chmod ugo-w "$PROJECT_ROOT/web/sites/default/settings.php"
# …

En prod : on déploie sur un tag. En staging : sur la branche courante. Et composer install --no-dev garantit qu'on tourne sans les dépendances de dev (phpstan, phpcs…). Tout le reste — la config Drupal à importer, les caches à vider, les backups — est délégué à des jobs GitLab qui suivent.

prod:deploy-config pour chaque site en parallèle, exécute drush deploy (qui fait updatedb + config:import + cache:rebuild) :

prod:deploy-config:
  extends: [.prod, .ssh, .drush-aliases]
  script:
    - $SSH_CHAIN 'bash -s' < ./scripts/deploy-config.sh \
        $PROJECT_ROOT $DRUPAL_SITE_PATH $DRUSH_BIN \
        $CI_ENVIRONMENT_NAME $CI_PIPELINE_IID \
        $CI_COMMIT_REF_NAME "@$SITE.main" $SITE
  needs: [prod:deploy]

prod:clear-cache, drush cr sur chaque site, en parallèle :

prod:clear-cache:
  extends: [.prod, .drush-aliases, .ssh]
  stage: postdeploy
  script:
    - $SSH_CHAIN "$PROJECT_ROOT/$DRUSH_BIN $DRUSH_ALIAS cr"

prod:postdeploy:backup, dump SQL horodaté de chaque site juste après le deploy. Permet de revenir en arrière facilement si une release casse quelque chose :

prod:postdeploy:backup:
  extends: [.ssh, .backup, .drush-aliases, .prod]
  script:
    - $SSH_CHAIN "$PROJECT_ROOT/$DRUSH_BIN @$SITE.main sql-dump \
        | gzip -9 > $PROJECT_ROOT/files/$SITE/dumps/`date +%Y-%m-%d_%H-%M-%S`-$SITE-post-$CI_COMMIT_REF_NAME.sql.gz"

prod:scheduled-backup, tourne sur un cron GitLab (chaque nuit). Idem mais en mode planifié.

prod:scheduled-clean, purge les dumps de plus de 7 jours :

script:
  - $SSH_CHAIN "find $PROJECT_ROOT/files/$SITE/dumps/ \
      -type f -mtime +$DAYS_DUMP_TO_KEEP -name '*.sql.gz' -delete"

prod:sentry-release, crée une release Sentry par site avec les commits associés :

prod:sentry-release:
  variables:
    RELEASE: "$CI_COMMIT_TAG"
    SENTRY_PROJECT: "$SITE"
  extends: [.sentry-sites, .prod, .sentry-release]

À la fin du pipeline, j'ai :

  • Le code déployé sur les 12 sites
  • Les CSS et JS recompilés et "rsyncés"
  • La config Drupal importée sur chaque site
  • Les caches vidés
  • Un dump SQL pre-deploy et un dump post-deploy par site
  • Une release Sentry par site avec les commits associés à cette version

Et tout ça tourne en ~5 à 10 minutes selon la taille de la mise à jour, grâce à la parallélisation par matrice.


7. Le workflow de mise à jour Drupal

C'est l'argument principal de cette archi. Voyons concrètement comment ça se passe pour une mise à jour de Drupal core :

cd uzinasit
composer update drupal/core --with-dependencies
lando drush-all updb -y # mise à jour de la DB
lando drush-all cex # export de la config
# On teste via les tests unitaires et manuels
git commit -am "feat(core): update Drupal 11.x.y"
git tag x.y.z
git push
git push --tags

Et la CI fait le reste : QA, build des assets, déploiement code sur la prod (un seul composer install), drush deploy en matrice sur les 12 sites, vidage de cache, backup post-deploy, releases Sentry. Le tout en moins de 10 minutes, en parallèle.

Et le composer.lock étant unique, j'ai la garantie absolue que mes 12 sites tournent exactement avec la même version de Drupal core et des mêmes contribs. Plus de dérive entre sites.

C'est ce que je voulais. Maintenance routinière → quasi zéro temps. La dernière fois qu'une vulnérabilité critique Drupal core a été publiée, j'ai appliqué la mise à jour en 5 minutes. Sans cette archi, j'aurais bloqué une demi-journée.

Idem pour les modules contrib : composer require drupal/un_module rend le module disponible pour les 12 sites. Charge à chaque site d'activer ce module ou pas (via son core.extension.yml dans files/<site>/config/sync/).


Pour conclure

Je suis très content de cette infra, 

  • La mise en place d'un nouveau site est maintenant très rapide
  • Mes sites sont maintenant tout le temps à jour
  • Backup simplifié pour l'ensemble des sites

Quelques limites :

  • Quand je fais des modifs concernant un site, c'est 12 sites qui sont publié, backupé, caches vidés... peut-être réflechir à un moyen de limiter le déploiement à un seul site quand ça n'est pas de la mise à jour de module / lib
  • Evidement je ne gère aucun site de client sur cette infrastructure.

Prochaine piste que le voudrais creuser : Les Drupal recipes pour scaffolder un site avec un set de modules prédéfinis (Actu, blog, site vitrine...)

N'hésitez-pas si vous avez des suggestions ou des questions

Contenus en rapport

Sideproject : Site et application pour photos de drone

Je me suis acheté en mai de cette année un petit drone DJI Flip avec lequel je m'amuse bien. Je voulais trouver un moyen de garder certaines photos que je prenais et les partager avec famille et amis.

Ajouter un commentaire

Ne sera pas publié
CAPTCHA
Désolé, pour ça, mais c'est le seul moyen pour éviter le spam...