Gestion de la configuration Drupal pilotรฉe par CI avec Jenkins et GitLab CI
1. Why CI-Driven Configuration Management Matters
Drupal's configuration system is one of the platform's greatest strengths โ and one of its most reliable sources of pain. The ability to export and import every piece of site configuration as YAML files is powerful, but only if everyone agrees on who is responsible for moving those files between environments. In most teams, that agreement never quite exists.
The classic problems are well-known to anyone who has shipped a Drupal site:
- Config drift โ staging diverges from production, production diverges from local, and nobody is sure which environment is canonical.
- "Works on staging but not on prod" โ because somebody updated a view or a field formatter on staging and never exported it.
- Manual
drush cimbreaking content โ a hurried import at 11 pm that deleted a content type field still referenced by live nodes.
The root cause of every one of these scenarios is the same: a human being is deciding when and whether configuration gets promoted. Humans forget. They skip steps under pressure. They make judgement calls that turn out to be wrong.
CI doesn't forget. A pipeline either passes or fails. It doesn't have a standup to get to. It doesn't know that the release is in twenty minutes. That determinism is exactly what configuration management needs.
The promise this article delivers on:
- Every configuration change is committed to Git before it touches any shared environment.
- The pipeline, not a developer, is responsible for validating and importing configuration.
- Promotion between environments requires zero manual steps.
- Config drift is a build failure, not a Slack message.
2. Core Principles We Learned From Real Projects
Configuration is code
If it changes the behaviour of the site, it belongs in Git. Full stop. A view, a content type, a performance setting, an image style โ all of it is code. Treat config files with the same discipline as PHP files: review them, version them, never edit them directly in a shared environment.
No manual drush cim in shared environments
The pipeline imports configuration. Developers do not. This rule sounds extreme until you experience the first time someone runs drush cim on production with uncommitted local changes still in their working directory.
Pipelines must fail fast on config drift
Importing configuration and then immediately exporting it should produce no diff. If it does, the build fails. This single rule catches more bugs than any other check we have added.
core.extension.yml was excluded from exports because "modules are managed by Composer anyway." Three months later a hotfix re-enabled a module that had deliberately been disabled in production. The hotfix was correct โ but a subsequent config import silently disabled the module again. That incident is why we now call core.extension.yml the most dangerous file you can ignore.3. High-Level Architecture
drush cex
config/sync
validate + import
Configuration flows in one direction only: from the developer's local export, through Git, through the CI pipeline, and into each environment.
The key insight is the separation of concerns:
| Actor | Responsibility | Never responsible for |
|---|---|---|
| Developer | Export config, commit to Git, open MR/PR | Importing config on any shared environment |
| CI Pipeline | Validate, import, verify, promote | Generating or editing config files |
| Environment | Running the site | Generating, storing, or exporting config |
Environments are consumers of configuration, never producers. The moment an environment becomes a source of truth for config, drift becomes inevitable.
4. Repository Structure That Scales
project-root/
โโโ composer.json
โโโ composer.lock
โโโ Jenkinsfile
โโโ .gitlab-ci.yml
โ
โโโ ci/
โ โโโ drupal-config-check.sh # Reusable validation script
โ โโโ drupal-deploy.sh
โ โโโ drupal-install.sh
โ
โโโ config/
โ โโโ sync/ # Config lives here โ committed to Git
โ โ โโโ core.extension.yml
โ โ โโโ system.site.yml
โ โ โโโ ...
โ โโโ splits/ # config_split overrides per environment
โ โโโ development/
โ โโโ staging/
โ โโโ production/
โ
โโโ web/
โโโ sites/
โ โโโ default/
โ โโโ settings.php # Committed, no secrets
โ โโโ settings.local.php # Gitignored, local overrides
โ โโโ settings.env.php # Loaded by CI from env vars
โโโ ...What lives in config/sync
All configuration YAML files generated by drush cex. This directory is the single source of truth for site configuration. It is committed, reviewed, and deployed like any other code.
What never goes into config
- Environment-specific hostnames, API keys, or credentials โ use environment variables and
settings.env.php. - Secrets of any kind โ inject via your CI secret store (Jenkins Credentials or GitLab CI Variables).
- Content โ do not use Default Content module as a crutch for what should be config.
// settings.php โ committed to Git, environment-agnostic
$settings['config_sync_directory'] = DRUPAL_ROOT . '/../config/sync';
// Load environment-specific values injected by CI or the host.
if (file_exists($app_root . '/' . $site_path . '/settings.env.php')) {
include $app_root . '/' . $site_path . '/settings.env.php';
}
// Load optional local overrides (gitignored).
if (file_exists($app_root . '/' . $site_path . '/settings.local.php')) {
include $app_root . '/' . $site_path . '/settings.local.php';
}// Generated by CI from environment variables โ never committed.
$databases['default']['default'] = [
'driver' => 'mysql',
'host' => getenv('DB_HOST'),
'database' => getenv('DB_NAME'),
'username' => getenv('DB_USER'),
'password' => getenv('DB_PASS'),
'port' => getenv('DB_PORT') ?: 3306,
'prefix' => '',
];
$settings['hash_salt'] = getenv('DRUPAL_HASH_SALT');
// Tell config_split which environment we are in.
$config['config_split.config_split.production']['status'] =
(getenv('APP_ENV') === 'production');5. Drupal Configuration Workflow
5.1 Local Development
The local workflow is the only place where the feedback loop between UI changes and config export needs to be fast and habitual. Every developer on the project follows the same rules:
- Make UI or code changes locally.
- Run
drush ceximmediately โ not at the end of the day. - Review the diff with
git diff config/sync. - Either commit the changes or discard them with
git checkout -- config/sync. - There is no in-between state.
# After making configuration changes via the Drupal UI or install hooks:
drush cex --yes
# Review what changed โ treat this like reviewing any code change:
git diff config/sync
# Stage and commit alongside the feature code:
git add config/sync
git commit -m "feat(search): add fulltext search API index config"
# Or discard if the change was exploratory and not ready:
git checkout -- config/syncWe enforce this with a lightweight Git pre-commit hook that warns (but does not block) when PHP or template files are staged without a corresponding change in config/sync:
#!/bin/bash
# Warn if module/theme code changed but config/sync didn't.
CHANGED_CODE=$(git diff --cached --name-only | grep -E '\.(php|module|theme|install)$')
CHANGED_CONFIG=$(git diff --cached --name-only | grep '^config/sync')
if [[ -n "$CHANGED_CODE" ]] && [[ -z "$CHANGED_CONFIG" ]]; then
echo "โ Warning: PHP/module files staged but no config/sync changes detected."
echo " Did you forget to run: drush cex ?"
echo " Proceeding anyway โ but double-check."
fi
exit 05.2 Feature Branches
Configuration lives alongside the code that requires it in the same branch and the same pull/merge request. A feature that adds a new content type should include both the PHP install hook (if any) and the YAML files for that content type in the same commit.
6. CI Responsibilities: What the Pipeline Must Enforce
Every pipeline that touches a shared Drupal environment must execute these steps in order:
- Install or restore the database โ a clean install or a sanitized production snapshot.
- Run database updates โ
drush updb. - Import configuration โ
drush cim --yes. - Re-export configuration โ
drush cex --yes. - Assert no diff โ if any YAML file changed, fail the build.
Step 4 and 5 together are the most important. Importing and then immediately re-exporting must produce an empty diff. If it doesn't, one of these things is true:
- A module is generating config on import (often a bug in the module).
- The UUID of a config entity doesn't match what's in the database.
- A config schema is incomplete, causing Drupal to normalise values differently than how they were exported.
- A developer edited config files by hand and introduced inconsistencies.
#!/bin/bash
set -euo pipefail
echo "=== Running Drupal database updates ==="
drush updb --yes
echo "=== Importing configuration from config/sync ==="
drush cim --yes
echo "=== Re-exporting to verify no pending changes ==="
drush cex --yes
echo "=== Checking for config drift ==="
if ! git diff --exit-code config/sync; then
echo ""
echo "โ FAIL: Configuration drift detected!"
echo " The config in Git does not match what Drupal produced after import+export."
echo " Diff shown above. Fix locally with 'drush cex' and commit."
exit 1
fi
echo "โ
Configuration is clean โ no drift detected."7. Jenkins Implementation (Battle-Tested)
7.1 Jenkinsfile Structure
We use declarative pipelines exclusively. Scripted pipelines offer more flexibility, but declarative pipelines are easier to read, easier to lint with jenkins-cli declarative-linter, and easier for new team members to understand.
pipeline {
agent { label 'drupal-php82' }
options {
buildDiscarder(logRotator(numToKeepStr: '20'))
timeout(time: 30, unit: 'MINUTES')
disableConcurrentBuilds()
}
environment {
DRUPAL_ROOT = "${WORKSPACE}/web"
COMPOSER_HOME = "${WORKSPACE}/.composer"
APP_ENV = 'ci'
DB_CREDS = credentials('drupal-ci-db') // Jenkins secret
}
stages {
stage('Checkout') {
steps {
checkout scm
sh 'git log --oneline -5'
}
}
stage('Composer Install') {
steps {
sh '''
composer install \
--no-interaction \
--prefer-dist \
--optimize-autoloader
'''
}
}
stage('Write Settings') {
steps {
// Generate settings.env.php from Jenkins credentials/env vars
sh '''
cat > web/sites/default/settings.env.php < 'mysql',
'host' => '127.0.0.1',
'database' => 'drupal_ci',
'username' => '${DB_CREDS_USR}',
'password' => '${DB_CREDS_PSW}',
'port' => 3306,
'prefix' => '',
];
\\$settings['hash_salt'] = '${DRUPAL_HASH_SALT}';
EOF
'''
}
}
stage('Site Install') {
steps {
sh '''
drush site-install minimal \
--yes \
--existing-config \
--account-name=admin \
--account-pass="${DRUPAL_ADMIN_PASS}"
'''
}
}
stage('Validate Config') {
steps {
sh 'bash ci/drupal-config-check.sh'
}
post {
failure {
sh 'git diff config/sync || true'
archiveArtifacts artifacts: 'config/sync/**/*.yml', allowEmptyArchive: true
}
}
}
stage('Deploy') {
when {
anyOf {
branch 'main'
branch 'release/*'
}
}
steps {
sh 'bash ci/drupal-deploy.sh'
}
}
}
post {
always {
sh 'drush cr || true'
cleanWs()
}
failure {
emailext(
subject: "[FAIL] ${JOB_NAME} #${BUILD_NUMBER}",
body: "Config validation failed. See: ${BUILD_URL}",
recipientProviders: [[$class: 'DevelopersRecipientProvider']]
)
}
}
}7.2 Jenkins-Specific Considerations
Shared Libraries
When managing more than two or three Drupal sites, extract the Drupal pipeline logic into a Jenkins Shared Library. Each project's Jenkinsfile then becomes a thin wrapper:
@Library('drupal-pipeline-lib@v2') _
drupalPipeline(
phpVersion: '8.2',
deployBranch: 'main',
dbCredentials: 'drupal-ci-db',
slackChannel: '#deployments'
)Workspace Cleanup
Always call cleanWs() in the post { always } block. Jenkins agents accumulate state from previous builds โ a previous build's settings.env.php or vendor directory can silently influence the current build. Be brutal: clean the workspace.
8. GitLab CI Implementation
8.1 .gitlab-ci.yml Structure
image: php:8.2-cli
stages:
- build
- test
- validate-config
- deploy
# โโ Shared variables โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
variables:
COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.cache/composer"
APP_ENV: "ci"
MYSQL_DATABASE: "drupal_ci"
MYSQL_ROOT_PASSWORD: "root"
# โโ Reusable cache โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
.cache-composer: &cache-composer
cache:
key:
files: [composer.lock]
paths:
- .cache/composer
- vendor/
policy: pull
# โโ Build stage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
composer:install:
stage: build
cache:
key:
files: [composer.lock]
paths:
- .cache/composer
- vendor/
policy: pull-push
script:
- composer install --no-interaction --prefer-dist --optimize-autoloader
artifacts:
paths:
- vendor/
- web/core/
- web/modules/contrib/
expire_in: 1 hour
# โโ Test stage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
phpunit:unit:
stage: test
<<: *cache-composer
script:
- ./vendor/bin/phpunit --testsuite=unit --log-junit=reports/phpunit.xml
artifacts:
reports:
junit: reports/phpunit.xml
# โโ Config validation stage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
drupal:validate-config:
stage: validate-config
<<: *cache-composer
services:
- name: mysql:8.0
alias: mysql
variables:
DB_HOST: mysql
DB_NAME: $MYSQL_DATABASE
DB_USER: root
DB_PASS: $MYSQL_ROOT_PASSWORD
before_script:
- bash ci/write-settings-env.sh
- drush site-install minimal --yes --existing-config
script:
- bash ci/drupal-config-check.sh
artifacts:
when: on_failure
paths:
- config/sync/
expire_in: 3 days
# โโ Deploy stage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
deploy:staging:
stage: deploy
environment:
name: staging
url: https://staging.example.com
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
script:
- bash ci/drupal-deploy.sh staging
deploy:production:
stage: deploy
environment:
name: production
url: https://example.com
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual # One-click promotion, human approval still required
script:
- bash ci/drupal-deploy.sh production8.2 GitLab CI Advantages
GitLab CI has several features that make it particularly clean for this workflow:
- First-class artifacts โ Failed config exports are automatically stored and browsable in the MR UI without any extra configuration.
- Native caching keyed on
composer.lockโ Composer installs are cheap after the first run. - Merge Request pipeline visibility โ Developers see config validation status directly on the MR before merge.
- Services block โ MySQL spins up as a sidecar with zero infrastructure overhead.
- Environments + manual gates โ The
when: manualrule on the production deploy gives you one-click promotion with a human checkpoint, without requiring a separate approval workflow.
#!/bin/bash
set -euo pipefail
TARGET_ENV="${1:-staging}"
echo "=== Deploying to: ${TARGET_ENV} ==="
# Sync built artifact to target (rsync, SSH, S3 โ adapt to your host)
rsync -az --delete \
--exclude='.git' \
--exclude='web/sites/default/files' \
--exclude='web/sites/default/settings.env.php' \
./ "deploy@${TARGET_ENV}.example.com:/var/www/drupal/"
echo "=== Running post-deploy commands on ${TARGET_ENV} ==="
ssh "deploy@${TARGET_ENV}.example.com" bash -s <<'REMOTE'
set -e
cd /var/www/drupal
drush updb --yes
drush cim --yes
drush cex --yes
# Final sanity check on the target environment itself
git diff --exit-code config/sync || (echo "โ Config drift on target!"; exit 1)
drush cr
echo "โ
Deploy complete."
REMOTE9. Environment-Specific Configuration Without Cheating
Not every configuration value should be the same across all environments. Search backends, logging verbosity, caching layers, and third-party API endpoints all legitimately differ. The question is how to handle that difference without compromising the integrity of your config pipeline.
The Correct Tools: config_split and config_ignore
config_split allows you to define sets of configuration that are active only in specific environments. Each split lives in its own directory and is activated via settings.php or settings.env.php based on an environment variable.
langcode: en
status: true
id: development
label: Development
description: 'Config active only on local/dev environments'
folder: '../config/splits/development'
module:
devel: 0
kint: 0
dblog: 0
theme: { }
blacklist: { }
graylist: { }$app_env = getenv('APP_ENV') ?: 'production';
// Activate the correct config_split based on the environment.
$config['config_split.config_split.development']['status'] = ($app_env === 'development');
$config['config_split.config_split.staging']['status'] = ($app_env === 'staging');
$config['config_split.config_split.production']['status'] = ($app_env === 'production');Real-world split examples
| Config item | Dev split | Stage split | Prod split |
|---|---|---|---|
| Search API backend | Database backend | Solr (small) | Solr (prod cluster) |
| Error logging | dblog, verbose | syslog | syslog + external APM |
| Performance modules | Disabled | Enabled | Enabled + CDN config |
| Mail transport | Mailhog / null mailer | Mailpit | SMTP / SendGrid |
Improper approaches โ don't do these:
- Editing configuration directly on the production database.
- Using
if ($settings['environment'] === 'prod')conditionals insidesettings.phpto swap out config values โ this bypasses the config system entirely and creates invisible runtime divergence. - Keeping separate Git branches per environment with different config files.
10. Promotion Between Environments
Promotion is not deployment. Deployment moves code and config to an environment. Promotion moves a validated artifact โ something that has already passed CI โ to the next environment in the chain.
In practice, this means the same Git SHA that was validated on dev is the SHA that reaches production. No last-minute commits. No hotfixes that skip CI. No cherry-picks that bypass the config check.
on feature branch / main
auto-deploy on merge
auto-deploy from develop
manual gate on main
The same validated artifact travels through environments. CI runs once; the result propagates.
The immutable builds philosophy means: what CI validated is what gets deployed. If a hotfix is truly necessary, it goes through a fast-track branch with its own CI run โ it does not bypass validation.
main branch with a required pipeline status check. GitLab's "protected branches" and Jenkins' "GitHub Branch Source" plugin both support this. If the pipeline hasn't passed, the branch cannot be deployed to production.11. Handling Edge Cases (You Will Hit These)
UUID Mismatches
Config entities carry UUIDs. If you install a fresh site and then try to import config from a different installation, Drupal will refuse with a UUID mismatch error. The solution is to always install with --existing-config or to set the site UUID after install:
# Read the UUID from the committed config and apply it to the fresh install:
SITE_UUID=$(grep "^uuid:" config/sync/system.site.yml | awk '{print $2}')
drush config-set "system.site" uuid "$SITE_UUID" --yes
drush cim --yesModule Enable/Disable Order
When enabling a new module via config, Drupal respects dependency order automatically during drush cim. However, if a module's install hook generates default config that conflicts with what's in config/sync, you may need to delete that default config and re-import. The pipeline will catch this as a drift.
Content-Dependent Configuration
Some config references content โ for example, a block that references a menu item by ID, or a view that filters by a taxonomy term. These references can differ between environments if content was created differently. The solution is to manage that content through migrations or default content modules, not through config.
Multisite Quirks
In a multisite setup, each site has its own config sync directory. The CI pipeline must run the config check for each site, not just the primary one. Use a loop:
#!/bin/bash
set -euo pipefail
for SITE_DIR in web/sites/*/; do
SITE=$(basename "$SITE_DIR")
[[ "$SITE" == "default" ]] && continue
[[ "$SITE" == "simpletest" ]] && continue
echo "--- Checking config for site: $SITE ---"
drush --uri="${SITE}" updb --yes
drush --uri="${SITE}" cim --yes
drush --uri="${SITE}" cex --yes
CONFIG_DIR="config/${SITE}/sync"
if ! git diff --exit-code "${CONFIG_DIR}"; then
echo "โ Config drift in site: $SITE"
exit 1
fi
done
echo "โ
All sites clean."Upgrading Drupal Core With Config Changes
Core updates sometimes ship with schema changes that affect existing config. Always run drush updb before drush cim, and run drush cex after to capture any schema-normalised changes. Commit those changes as part of the core upgrade branch โ do not be surprised by them in the pipeline.
12. Operational Safety Nets
Read-Only Config in Production
Consider enabling the Config Readonly module in production. It prevents any configuration change via the admin UI โ a hard block that reinforces the "no manual changes" rule at the application level.
if (getenv('APP_ENV') === 'production') {
$settings['config_readonly'] = TRUE;
}Post-Deploy Verification
# Verify no pending config changes remain after deploy:
drush config-status 2>&1 | grep -v 'No differences' && { \
echo "โ Unexpected config differences after deploy"; exit 1; \
} || echo "โ
Config status clean"
# Verify caches are warm:
drush cr
drush php-eval "echo \Drupal::state()->get('system.cron_last');"Rollback Strategy
Rollback is a Git operation, not a database operation. If a deployment causes an issue:
- Identify the last good commit SHA.
- Trigger a pipeline run on that SHA.
- The pipeline re-deploys the previously validated artifact.
Manual database surgery to undo a config import is never the answer โ it bypasses all the safety checks and typically creates more drift than it fixes.
Auditing: Who Changed Config, When, and Why
Because every config change goes through Git, your audit trail is your Git history. Use meaningful commit messages and enforce them with a commit-msg hook or a CI lint step:
$ git log --oneline config/sync/views.view.articles.yml
a3f8c12 feat(views): add taxonomy filter to articles view (PROJ-421)
9e1b307 fix(views): remove exposed sort causing query timeout (PROJ-389)
4d22a91 chore(config): export after Search API Solr 4.3.0 upgrade13. Common Mistakes We No Longer Make
| Mistake | Why it hurts | The fix |
|---|---|---|
Running drush cim manually on staging or prod | Imports an unknown state โ possibly including uncommitted local changes | Only the pipeline imports config on shared environments |
| Allowing UI changes on staging | Staging becomes a source of truth, creating divergence from Git | Enable config_readonly on all shared environments |
| Trusting developers to "remember to export" | They won't, under pressure | Pre-commit hooks warn; CI failure enforces |
| Not failing on config drift | Silent divergence accumulates until something breaks spectacularly | The drush cim โ cex โ git diff --exit-code triplet is mandatory |
| Separate branches per environment with different config | Merging becomes a conflict nightmare; no single source of truth | One branch of config, split by config_split |
Skipping drush updb before drush cim | Schema updates can cause import failures or silent data loss | Always: updb โ cim โ cex โ diff |
14. Results After Applying This Pattern
After rolling this approach out across a portfolio of Drupal sites โ ranging from a single small site to an agency managing dozens of installations โ the results have been consistent:
drush cim runs on production in 18 monthsBeyond the numbers, the cultural shift matters most. Config discussions move from Slack to Git history. Instead of "did anyone change the image styles on staging?", the question becomes "is there a commit for that?" โ and there always is, or the change didn't happen.
New developers no longer need to learn the unwritten rules of "which environment is canonical right now." The answer is always Git. The pipeline enforces it.
15. Final Recommendations
- Start strict, relax only with reasons. It's easier to loosen a rule that proves unnecessary than to tighten one that was never there.
- Treat every config diff as a build failure. No exceptions, no "we'll fix it after the release."
- CI is the source of truth for what gets deployed โ not a developer's laptop, not a staging environment, not a Slack decision.
- The
updb โ cim โ cex โ diffsequence is inviolable. If you skip any step, you are flying blind. - Use
config_splitfor genuine environment differences. Do not usesettings.phpconditionals as a shortcut. - Enable config_readonly on all shared environments. Make accidental UI changes impossible, not just discouraged.
- Archive failed config exports as CI artifacts. Developers need to see exactly what drifted, not just that a diff existed.
- Rollback via Git, never via database surgery. The pipeline is the safest path in both directions.
Both Jenkins and GitLab CI are equally capable of enforcing this discipline. GitLab CI requires less infrastructure and has better native artifact support; Jenkins offers more flexibility for complex enterprise environments and excellent shared library support for standardising pipelines across many projects. Choose the tool that fits your organisation โ the principles in this article apply regardless.
The goal was never to make configuration management complicated. The goal is to make it boring: a routine step that always works, that no one has to think about, that never causes a 11 pm incident. With a properly implemented CI pipeline, boring is exactly what you get.
Ivan Abramenko, Principal Drupal Architect
ivan.abramenko@drupalbook.org
projects@drupalbook.org