logo

Types de blocs supplรฉmentaires (EBT) โ€“ Nouvelle expรฉrience de Layout Builderโ—

Types de blocs supplรฉmentaires (EBT) โ€“ types de blocs stylisรฉs et personnalisables : diaporamas, onglets, cartes, accordรฉons et bien dโ€™autres. Paramรจtres intรฉgrรฉs pour lโ€™arriรจre-plan, la boรฎte DOM, les plugins JavaScript. Dรฉcouvrez dรจs aujourdโ€™hui le futur de la crรฉation de mises en page.

Dรฉmo des modules EBT Tรฉlรฉcharger les modules EBT

โ—Types de paragraphes supplรฉmentaires (EPT) โ€“ Nouvelle expรฉrience Paragraphes

Types de paragraphes supplรฉmentaires (EPT) โ€“ ensemble de modules basรฉ sur les paragraphes analogiques.

Dรฉmo des modules EPT Tรฉlรฉcharger les modules EPT

GLightbox is a pure javascript lightbox (Colorbox alternative without jQuery)โ—

It can display images, iframes, inline content and videos with optional autoplay for YouTube, Vimeo and even self-hosted videos.

Demo GLightbox Download GLightbox

Dรฉfilement

Gestion de la configuration Drupal pilotรฉe par CI avec Jenkins et GitLab CI

16/04/2026, by Ivan

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 cim breaking 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.
Assumptions: You're running Drupal 10 or 11, using a Git-based workflow with feature branches, and deploying to at least two shared environments (e.g., staging and production). Your team uses either Jenkins or GitLab CI โ€” or both.

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.

A senior developer was debugging a slow search page on production. He tweaked the Search API index settings via the UI, confirmed performance improved, and closed the ticket. Two weeks later, a release imported configuration from Git โ€” overwriting his UI changes โ€” and search performance collapsed. No one connected the dots for three days. After that incident, we made the rule explicit: every UI change that is not immediately exported and committed is considered lost.
On a media-heavy site, 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

๐Ÿ‘จโ€๐Ÿ’ป Developer
drush cex
โ†’
๐Ÿ“ Git Repository
config/sync
โ†’
โš™ CI Pipeline
validate + import
โ†’
๐ŸŒ Dev
โ†’
๐ŸŒ Stage
โ†’
๐ŸŒ Prod

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:

ActorResponsibilityNever responsible for
DeveloperExport config, commit to Git, open MR/PRImporting config on any shared environment
CI PipelineValidate, import, verify, promoteGenerating or editing config files
EnvironmentRunning the siteGenerating, 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

Directory Tree project root
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.
PHP web/sites/default/settings.php
// 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';
}
PHP web/sites/default/settings.env.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 cex immediately โ€” 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.
Shell Local development workflow
# 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/sync

We 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:

Shell .git/hooks/pre-commit
#!/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 0

5.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.

Catching broken dependencies early: If branch A adds a new field and branch B changes the display of that same field, merging B before A will produce a config import error. This is exactly the right outcome โ€” you want the pipeline to catch this on the branch, not on production.

6. CI Responsibilities: What the Pipeline Must Enforce

Every pipeline that touches a shared Drupal environment must execute these steps in order:

  1. Install or restore the database โ€” a clean install or a sanitized production snapshot.
  2. Run database updates โ€” drush updb.
  3. Import configuration โ€” drush cim --yes.
  4. Re-export configuration โ€” drush cex --yes.
  5. 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.
Shell ci/drupal-config-check.sh
#!/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."
Why re-export after import is non-negotiable: Without the re-export step, you only know that the import succeeded. You do not know whether the config that was imported matches what Drupal actually stored internally. Only re-exporting and diffing closes that gap.
 

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:

Groovy Jenkinsfile (thin wrapper using shared library)
@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.

Never reuse a database between pipeline runs unless you are explicitly testing upgrade paths. A leftover database from a previous build can mask UUID mismatches, missing migrations, and module install order issues.

8. GitLab CI Implementation

8.1 .gitlab-ci.yml Structure

YAML .gitlab-ci.yml
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 production

8.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: manual rule on the production deploy gives you one-click promotion with a human checkpoint, without requiring a separate approval workflow.
Shell ci/drupal-deploy.sh
#!/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."
REMOTE

9. 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.

YAML config/sync/config_split.config_split.development.yml
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: {  }
PHP web/sites/default/settings.env.php (environment activation)
$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 itemDev splitStage splitProd split
Search API backendDatabase backendSolr (small)Solr (prod cluster)
Error loggingdblog, verbosesyslogsyslog + external APM
Performance modulesDisabledEnabledEnabled + CDN config
Mail transportMailhog / null mailerMailpitSMTP / SendGrid

Improper approaches โ€” don't do these:

  • Editing configuration directly on the production database.
  • Using if ($settings['environment'] === 'prod') conditionals inside settings.php to 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.

โœ… CI Validates SHA
on feature branch / main
โ†’
๐ŸŒ Dev
auto-deploy on merge
โ†’
๐ŸŒ Stage
auto-deploy from develop
โ†’
๐ŸŒ Prod
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.

Preventing last-minute hotfixes from skipping config checks: Protect your 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:

Shell
# 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 --yes

Module 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:

Shellci/multisite-config-check.sh
#!/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.

PHPweb/sites/default/settings.env.php
if (getenv('APP_ENV') === 'production') {
  $settings['config_readonly'] = TRUE;
}

Post-Deploy Verification

ShellPost-deploy checks
# 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:

  1. Identify the last good commit SHA.
  2. Trigger a pipeline run on that SHA.
  3. 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:

ShellExample commit history
$ 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 upgrade

13. Common Mistakes We No Longer Make

MistakeWhy it hurtsThe fix
Running drush cim manually on staging or prodImports an unknown state โ€” possibly including uncommitted local changesOnly the pipeline imports config on shared environments
Allowing UI changes on stagingStaging becomes a source of truth, creating divergence from GitEnable config_readonly on all shared environments
Trusting developers to "remember to export"They won't, under pressurePre-commit hooks warn; CI failure enforces
Not failing on config driftSilent divergence accumulates until something breaks spectacularlyThe drush cim โ†’ cex โ†’ git diff --exit-code triplet is mandatory
Separate branches per environment with different configMerging becomes a conflict nightmare; no single source of truthOne branch of config, split by config_split
Skipping drush updb before drush cimSchema updates can cause import failures or silent data lossAlways: 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:

~80%
Reduction in config-related deployment incidents
< 1 day
New developer onboarding to the config workflow
0
Manual drush cim runs on production in 18 months
100%
Config changes traceable to a Git commit and MR

Beyond 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 โ†’ diff sequence is inviolable. If you skip any step, you are flying blind.
  • Use config_split for genuine environment differences. Do not use settings.php conditionals 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.
This approach scales. Whether you are a solo developer on one site or an agency managing fifty Drupal installations, the rules are the same. The pipeline doesn't care about team size or release pressure. It fails or it passes. That consistency is the entire point.

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.

Technical and architectural inquiries
Ivan Abramenko, Principal Drupal Architect
ivan.abramenko@drupalbook.org
Project inquiries
projects@drupalbook.org