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 :
- La vue d'ensemble de l'architecture
- Comment une seule codebase Drupal sert douze sites
- L'environnement de dev local avec Lando
- Les scripts spécifiques qui me font gagner un temps fou
- La commande
drush uzcpour ajouter un site - Le déploiement via GitLab CI (matrix sur les sites)
- 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 seulcomposer.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.site2.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 exempleweb/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.siteen 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 :
- 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.
- Isolation totale :
drop database kgaut;ne touchera jamais le dashboard. - Dumps simples : un fichier
.sql.gzpar site, qu'on importe sans réfléchir à des préfixes. Le même dump fonctionne en local et en prod. - 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 startEt 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 Lando | Ce qu'elle fait |
|---|---|
lando front:build | npm i + build des thèmes (je pourrais isoler, mais j'ai choisi la simplicité |
lando db-export-all | Dump SQL horodaté de chaque base, dans files/<site>/dumps/ |
lando drush-all | Exécute la même commande Drush sur les 12 sites, séquentiellement |
lando db-get-all | Rapatrie le dernier dump prod de tous les sites via SSH |
lando db-import-all | Importe 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-all | Idem mais pour tous les sites détectés |
lando phpstan | Analyse PHPStan sur les modules custom |
lando phpcs | Analyse PHPCS |
lando rector | Rector (refactoring automatique guidé) |
lando phpunit | PHPUnit |
lando grumphp | Suite 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 :
web/sites/sites.php(mapping hostname → dossier)drush/sites/<alias>.site.yml(alias Drush).lando.yml(service MariaDB, proxy, PMA, tooling…).env(4 variables)files/<alias>/(création des dossiers)web/sites/<alias>/(settings.php + services.yml).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 DBEt 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 customphpcs: coding standards Drupalphpunit: 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
J'ai présenté lors du Drupalcamp de Rennes en mars 2024 mon nouveau sideproject : un ensemble de jobs factorisés pour gitlab ci.
La problématique était la suivante :
Petit sideproject, développé en drupal 9 pour gérer mes projets de maintenance et suivre mon temps passé sur chaque ticket.
Le projet est présenté en détails sur un article de blog dédié.
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