Deployment and CI/CD

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

This page was checked on 2026-10-30 against Drupal 11.4.x, Drush 13 and 14 and PHP 8.3 or later. Drupal 10 reaches end of life on 2026-12-09 and Drupal 12.0.0 is scheduled for the week of 2026-12-07, so none of the pipelines below target Drupal 10 for a new site. It is written for developers and administrators who must move the code, configuration and database schema of a site from a laptop to staging and production repeatably, and for module maintainers who want automated tests on drupal.org projects.

The running example is the Meridian Museum site: the custom module meridian_collection, the theme meridian, and three environments (DDEV on each laptop, staging and production). The configuration system, settings.php and the Update API are explained in Configuration management, settings.php and environments and Update API and post-updates; this page ties them into one delivery pipeline and does not repeat them. General container and pipeline techniques are in Docker CI/CD with GitHub Actions and GitOps and CI/CD; hosting choices are in Requirements and hosting, and container images in Docker and containers.

What a Drupal deployment moves

A release of a Drupal site has four separate parts, and each travels differently:

  • Code: custom modules and themes, composer.json, composer.lock, the exported configuration and the scaffold files. It is built once and is identical on every environment.

  • Vendor code and core: everything Composer installs into vendor/, web/core, web/modules/contrib and web/themes/contrib. It is reproduced from composer.lock, not edited on the server.

  • Configuration: the YAML files in the sync directory, imported into the database of each environment.

  • Content and state: nodes, users, files and State values. They live only where they were created and are never deployed; moving production content down to staging is a separate sanitised-copy task covered in Backups and disaster recovery.

Composer is the only supported way to add or update code since module upload and UI updates were removed (10.4/11.0 for upload, 11.2 for updates), so a pipeline always starts from Composer. Automatic Updates and Project Browser still need a writable codebase and patch-level core updates only; they are not a substitute for a pipeline (see Automatic Updates and Project Browser). Older books such as Drupal 10 Development Cookbook (2023) show the composer install and drush deploy workflow correctly but assume Drush 12, PHP 8.1 and an Update Manager based flow; on 11.x the same workflow applies with the versions above and no update from the browser.

Build once, deploy the artefact

The recommended shape is a build artefact: CI checks out a commit, runs Composer and any front-end build, and packages the result. The same archive is promoted from staging to production, so production never runs composer install against the internet and never compiles anything.

# Run from the project root in CI; PHP 8.3+ and Composer 2 are required.
composer validate --strict
composer install --no-dev --prefer-dist --no-progress --optimize-autoloader --classmap-authoritative

# Front-end build of the custom theme (see the front-end tooling page).
npm --prefix web/themes/custom/meridian ci
npm --prefix web/themes/custom/meridian run build

# Package. Exclude Git data, local files and anything environment-specific.
tar --exclude='.git' --exclude='web/sites/*/files' --exclude='web/sites/*/settings.local.php' \
    --exclude='node_modules' -czf meridian-build.tar.gz \
    composer.json composer.lock config recipes vendor web drush

What the flags do:

  • composer install reads composer.lock, so the artefact contains exactly the versions that were tested. Never run composer update in a deployment.

  • --no-dev skips require-dev: PHPUnit, PHPStan, Coder, drupal/core-dev, Devel and other tools that should not exist on a production server. Devel and Webprofiler are a security risk if installed there.

  • --optimize-autoloader builds a class map; --classmap-authoritative makes the autoloader trust it and skip file system lookups. With the authoritative map a class added after the build is not found, which is exactly what a read-only artefact needs and what breaks a site that someone patched by hand.

  • --prefer-dist downloads archives instead of cloning Git repositories.

Two pitfalls follow from --no-dev. First, Drush must be in require, not require-dev, because drush deploy runs on the server from vendor/bin/drush. Second, any module that is enabled in the exported configuration must be in require: a module installed only as a development dependency is listed in core.extension.yml, the import then fails with a missing-module error, and the deployment stops half-way.

{
    "name": "meridian/museum-site",
    "type": "project",
    "require": {
        "php": ">=8.3",
        "composer/installers": "^2.3",
        "drupal/core-composer-scaffold": "^11.4",
        "drupal/core-recommended": "^11.4",
        "drupal/config_split": "^2.0",
        "drush/drush": "^13.6 || ^14"
    },
    "require-dev": {
        "drupal/core-dev": "^11.4",
        "phpstan/phpstan": "^2.0"
    },
    "config": {
        "sort-packages": true,
        "allow-plugins": {
            "composer/installers": true,
            "drupal/core-composer-scaffold": true
        }
    },
    "extra": {
        "drupal-scaffold": { "locations": { "web-root": "web/" } }
    }
}

The drupal/core-recommended metapackage no longer pins Guzzle, Twig or the Symfony polyfills, so a lock file is the only thing that makes a build reproducible. The scaffold plugin (composer drupal:scaffold) runs on install and writes index.php, .htaccess and robots.txt; a site that customises robots.txt should keep it in the scaffold configuration and add Disallow: /search? on 11.4.

Files that must never be part of the artefact: settings.local.php, any file with credentials, and the public or private files directories. settings.php itself is committed without secrets and reads credentials from the environment, as described in settings.php and environments. The production settings.php and sites/default/files are made read-only or symlinked to shared storage by the deploy step.

The deployment sequence

flowchart LR A[Git commit] --> B[CI: validate, lint, test] B --> C[Build artefact<br/>composer install --no-dev] C --> D[Staging deploy] D --> E{Checks pass?} E -- yes --> F[Approval] F --> G[Production deploy<br/>same artefact] E -- no --> H[Fix and repeat] G --> I[drush deploy<br/>updatedb, config:import, cache:rebuild, deploy:hook]

On the server, after the new code is in place, drush deploy runs the whole database and configuration sequence in a fixed order: updatedb, config:import, cache:rebuild, deploy:hook and, on Drupal 11.2 and later, cache:warm. The Drush documentation also gives the three update function types and their position: hook_update_N() and hook_post_update_NAME() run in the updatedb step (post-update functions before the configuration import), and hook_deploy_NAME(), a Drush feature kept in MODULE.deploy.php, runs afterwards. The order is explained in Configuration management.

#!/usr/bin/env bash
# deploy.sh -- run on the server from the release directory.
set -euo pipefail
DRUSH="vendor/bin/drush --root=$PWD/web --uri=${SITE_URI:?SITE_URI is required}"

$DRUSH state:set system.maintenance_mode 1 --input-format=integer
$DRUSH deploy --yes -v
$DRUSH state:set system.maintenance_mode 0 --input-format=integer
$DRUSH config:status

Notes on the script:

  • --yes is required because CI has no terminal; without it updatedb and config:import ask for confirmation and the job hangs or aborts.

  • set -euo pipefail stops on the first failure. A failed config:import leaves the site in maintenance mode, which is the safe state; do not turn maintenance mode off in a trap.

  • config:status at the end prints nothing but "No differences" when the import was complete. A non-empty result in CI means configuration was changed on the server by hand.

  • drush config:import aborts when the sync directory has a different site UUID, which happens when staging was installed fresh instead of cloned from production. Fix that once with a database copy, not with config:set system.site uuid.

Configuration import in the pipeline

The sync directory is set in settings.php through $settings['config_sync_directory']; the deploy job must find the same directory that developers exported to. Three pipeline practices pay off:

  • Fail the build when exported configuration is stale. After installing the site from configuration in CI, run drush config:status and fail when it reports differences; developers then see in the merge request that they forgot to export.

  • Install CI sites from configuration. drush site:install --existing-config creates a database from the committed YAML, proving that the repository alone can rebuild the site. Config Split profiles (dev, stage, prod) are selected by environment variable.

  • Never let production drift. Use Config Ignore sparingly and consider locking changes in production with the read-only configuration approach in the configuration page; a setting changed on production is overwritten by the next import.

# CI job: prove the repository can rebuild the site and that exports are current.
vendor/bin/drush site:install minimal --existing-config --yes \
  --db-url="mysql://root@127.0.0.1:3306/meridian" --account-name=admin
vendor/bin/drush config:status
vendor/bin/drush config:import --diff --yes

The admin account password in CI comes from an environment variable or is generated; it is never written into the repository. Configuration that contains keys, such as an AI provider key, is stored with the Key module reading from the environment, so exported YAML holds only a key name.

Zero-downtime concerns

Drupal does not give atomic deployments by itself, and a small site is usually deployed with a few seconds of maintenance mode. A deployment is "zero downtime" only to the extent that the following stay compatible while old and new code run side by side:

  • Database schema. updatedb changes tables while the old code may still be serving requests. Make changes additive in one release (new column, nullable) and remove the old column in a later release, after all servers run new code. Entity-definition updates and field storage changes are the riskiest.

  • Configuration. config:import changes behaviour immediately, in the middle of a deploy, and imports do not run in a single database transaction across all objects. A half-applied import is visible to visitors.

  • Code files and OPcache. Replacing files in place can mix old and new classes in one request. Deploy into a new release directory, switch a current symlink, and reload PHP-FPM (or set opcache.revalidate_path) so that stale paths are not cached; see OPcache, JIT and performance.

  • Cache. cache:rebuild clears every cache backend, so the first requests after a deploy are slow. Warm the cache (the cache:warm step on 11.2+) and, behind Varnish or a CDN, purge by cache tag rather than flushing everything; see Performance and caching.

  • Multiple web servers. Every server needs the same release before the database is updated, and shared files (sites/default/files, the private path) must be on shared storage. Run drush deploy once, from one node.

# Symlink-switch release layout on the server.
RELEASE=/var/www/meridian/releases/$(date +%Y%m%d%H%M%S)
mkdir -p "$RELEASE" && tar -xzf meridian-build.tar.gz -C "$RELEASE"
ln -sfn /var/www/meridian/shared/files "$RELEASE/web/sites/default/files"
ln -sfn /var/www/meridian/shared/settings.local.php "$RELEASE/web/sites/default/settings.local.php"
( cd "$RELEASE" && ./deploy.sh )
ln -sfn "$RELEASE" /var/www/meridian/current
sudo systemctl reload php8.3-fpm
ls -1dt /var/www/meridian/releases/* | tail -n +6 | xargs -r rm -rf

Rolling back is the weak point. Code can be rolled back by moving the symlink, but updatedb cannot be reversed: Drupal has no down-migrations, and a rollback after a schema change needs the database backup taken just before the deploy. Take it in the pipeline (drush sql:dump --gzip) and keep it with the release, as described in Backups and disaster recovery. The compatible alternative is to roll forward with a fix.

When a CDN or reverse proxy is in front, it must pass the HTMX headers (HX-Request, HX-Redirect) from Drupal 11.3 on, or HTMX-based forms fail only in production.

GitHub Actions pipeline

The workflow below builds on every push, deploys staging from main, and deploys production only after a reviewer approves the production environment. Secrets are read from the secrets context and never written into the file.

name: meridian-site
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    services:
      db:
        image: mariadb:11.4
        env:
          MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "1"
          MARIADB_DATABASE: meridian
        ports: ["3306:3306"]
        options: >-
          --health-cmd="healthcheck.sh --connect --innodb_initialized"
          --health-interval=5s --health-retries=10
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"
          extensions: gd, pdo_mysql, opcache
          tools: composer:v2
      - uses: actions/cache@v4
        with:
          path: ~/.composer/cache
          key: composer-${{ hashFiles('composer.lock') }}
      - run: composer install --prefer-dist --no-progress
      - run: vendor/bin/phpcs --standard=Drupal,DrupalPractice web/modules/custom web/themes/custom
      - run: vendor/bin/phpstan analyse web/modules/custom
      - run: >-
          vendor/bin/drush site:install minimal --existing-config --yes
          --db-url=mysql://root@127.0.0.1:3306/meridian --account-name=admin
      - run: vendor/bin/drush config:status
      - run: vendor/bin/phpunit -c web/core web/modules/custom
      - name: Build production artefact
        run: |
          composer install --no-dev --prefer-dist --no-progress --optimize-autoloader --classmap-authoritative
          tar --exclude='.git' -czf meridian-build.tar.gz composer.json composer.lock config vendor web drush deploy.sh
      - uses: actions/upload-artifact@v4
        with:
          name: meridian-build
          path: meridian-build.tar.gz

  deploy-staging:
    if: github.ref == 'refs/heads/main'
    needs: build
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/download-artifact@v4
        with: { name: meridian-build }
      - name: Copy and deploy
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          SSH_HOST: ${{ vars.DEPLOY_HOST }}
        run: |
          install -m 600 /dev/null key && printf '%s\n' "$SSH_KEY" > key
          scp -i key -o StrictHostKeyChecking=accept-new meridian-build.tar.gz "deploy@$SSH_HOST:/tmp/"
          ssh -i key "deploy@$SSH_HOST" 'bash /var/www/meridian/bin/release.sh /tmp/meridian-build.tar.gz'

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/download-artifact@v4
        with: { name: meridian-build }
      - name: Copy and deploy
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          SSH_HOST: ${{ vars.DEPLOY_HOST }}
        run: |
          install -m 600 /dev/null key && printf '%s\n' "$SSH_KEY" > key
          scp -i key -o StrictHostKeyChecking=accept-new meridian-build.tar.gz "deploy@$SSH_HOST:/tmp/"
          ssh -i key "deploy@$SSH_HOST" 'bash /var/www/meridian/bin/release.sh /tmp/meridian-build.tar.gz'

Points about the workflow:

  • The environment: keys bind each job to a GitHub environment, which scopes secrets and variables and can require reviewers. DEPLOY_SSH_KEY is defined separately for staging and production, so staging jobs can never read the production key. Secrets are not available to workflows triggered from forks, which is the intended protection.

  • phpunit for a project in web/modules/custom needs SIMPLETEST_DB and SIMPLETEST_BASE_URL for Kernel and Functional tests; see Automated testing. Pin third-party actions to a commit SHA in security-sensitive repositories.

  • The build job installs with dev dependencies to run tests and then reinstalls with --no-dev for the artefact. The alternative is two jobs, which keeps the test job output untouched.

  • StrictHostKeyChecking=accept-new trusts the host on first use; for production pin the known-hosts line in a variable instead.

GitLab CI pipeline

A project hosted on GitLab can express the same stages in .gitlab-ci.yml. Variables such as DEPLOY_SSH_KEY are defined under Settings > CI/CD > Variables, marked masked and protected, and limited to the protected main branch.

stages: [validate, build, staging, production]

default:
  image: php:8.3-cli
  before_script:
    - apt-get update -qq && apt-get install -y -qq git unzip libpng-dev libzip-dev
    - docker-php-ext-install gd zip pdo_mysql
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

lint:
  stage: validate
  script:
    - composer install --prefer-dist --no-progress
    - vendor/bin/phpcs --standard=Drupal,DrupalPractice web/modules/custom
    - vendor/bin/phpstan analyse web/modules/custom

build:
  stage: build
  script:
    - composer install --no-dev --prefer-dist --optimize-autoloader --classmap-authoritative
    - tar --exclude='.git' -czf meridian-build.tar.gz composer.json composer.lock config vendor web drush deploy.sh
  artifacts:
    paths: [meridian-build.tar.gz]
    expire_in: 2 weeks

.deploy:
  script:
    - 'command -v ssh-agent >/dev/null || (apt-get install -y -qq openssh-client)'
    - eval "$(ssh-agent -s)"
    - echo "$DEPLOY_SSH_KEY" | tr -d '\r' | ssh-add -
    - scp meridian-build.tar.gz "deploy@$DEPLOY_HOST:/tmp/"
    - ssh "deploy@$DEPLOY_HOST" 'bash /var/www/meridian/bin/release.sh /tmp/meridian-build.tar.gz'

deploy_staging:
  extends: .deploy
  stage: staging
  environment: { name: staging, url: "https://staging.meridian.example" }
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy_production:
  extends: .deploy
  stage: production
  environment: { name: production, url: "https://www.meridian.example" }
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual

The when: manual rule makes the production job a button that a maintainer presses after checking staging. For Kubernetes-based hosting, replace the SSH step with an image build and a GitOps promotion as described in GitOps and CI/CD.

Environments

An environment is a database, a file store, a set of environment variables and a Config Split profile. A typical Meridian setup:

Environment Purpose Configuration Data

Local (DDEV)

Development and debugging

dev split: Devel, Twig debugging, disabled caches, development.services.yml

Sanitised copy of production or a fixture set

CI

Disposable build verification

Installed from the exported configuration

None; created and discarded per run

Staging

Acceptance and release rehearsal

stage split, outgoing mail diverted, search engines blocked

Periodic sanitised copy of production

Production

Live site

prod split, caches and aggregation on, read-only files, security hardening

Real content and users

Rules that avoid the usual accidents: never expose staging to search engines (HTTP authentication and noindex), divert outgoing mail with a mail-catching service, sanitise user data when copying a production database down, and give each environment its own keys and hash_salt from the environment. Configuration that must differ per environment is set with Config Split or $config overrides in settings.php; the trade-offs are in Configuration management. Hardening of the production host is in Security hardening.

Secrets and security

  • Credentials, hash_salt and API keys come from the CI secret store and from environment variables on the server. Never commit them, never place them in exported configuration, and never print them: shell tracing (set -x) and composer config calls echo values into job logs.

  • The deploy user on the server should own only the release directory and be unable to use sudo for anything but a PHP-FPM reload. The web server user reads the code and writes only the files directories.

  • Production code must be read-only for the web server user; settings.php is set to mode 440 or 444 after the deployment.

  • Keep a development tool out of the artefact with --no-dev, and check that web/core/tests, the Markdown readme files and the example .php.dist files under sites/default are not reachable over HTTP.

  • Run composer audit in CI to fail builds on known advisories, and subscribe to Drupal security advisories; see Updates, Composer and security advisories.

  • Scan the repository for committed secrets before every merge.

Common errors and pitfalls

Symptom Cause and fix

drush: command not found on the server

Drush is in require-dev and --no-dev removed it. Move drush/drush to require.

Import fails with a missing module or theme

A module listed in core.extension.yml is a development dependency, or the lock file was not committed.

"Site UUID in source storage does not match the target"

Staging was installed from scratch. Restore a database copy instead of overriding the UUID.

updatedb prompts and the job hangs

No --yes. Always pass --yes in automation.

New class not found after deployment

The authoritative class map was built before the file was added, or PHP-FPM still serves old OPcache entries.

Changes made in the UI disappear

The next config:import overwrote them; export first or lock production configuration.

Everyone is logged out after deploy

hash_salt, the session cookie domain or the session storage changed between releases.

Site stays in maintenance mode

A step failed before the final state:set. Fix the cause, rerun drush deploy, then disable maintenance mode.

CDN serves stale pages after deploy

Cache tags were not purged. Purge by tag from a deploy hook or a purge module rather than only running cache:rebuild.

The Drupal Association GitLab CI templates for contributed projects

Drupal.org projects (modules, themes, recipes and distributions) are hosted on git.drupalcode.org, a GitLab instance. The Drupal Association maintains a shared set of CI templates in the gitlab_templates project, documented at project.pages.drupalcode.org/gitlab_templates. They are for testing a contributed project, not for deploying a site, and they replace the old DrupalCI system and the patch-testing workflow: GitLab CI cannot test patch files, so contributions go through merge requests.

A project adds the template by placing a .gitlab-ci.yml in its root. The recommended content is a copy of gitlab-ci/template.gitlab-ci.yml on the main branch of the drupal/gitlab_templates project. That file includes the remote definitions, so nothing else needs copying; the documented remote form is:

include:
  - remote: https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/includes/include.drupalci.main.yml
  - remote: https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/includes/include.drupalci.variables.yml
  - remote: https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/includes/include.drupalci.hidden-variables.yml
  - remote: https://git.drupalcode.org/project/gitlab_templates/-/raw/default-ref/includes/include.drupalci.workflows.yml

variables:
  _TARGET_PHP: "8.3"
  OPT_IN_TEST_NEXT_MINOR: "1"
  OPT_IN_TEST_MAX_PHP: "1"

phpcs:
  allow_failure: false
phpstan:
  allow_failure: false

The jobs are grouped in three stages. Build jobs: composer (assembles a Drupal codebase with your project and its dependencies) and composer-lint. Validation jobs: cspell, eslint, phpcs, phpstan, stylelint, twig-cs-fixer and secret-detection. Testing jobs: phpunit, nightwatch and upgrade-status; a pages job publishes a documentation site and a drupal-cms job tests against Drupal CMS. By default the pipeline runs for merge request events, commit tags, upstream pipeline calls, commits to the default branch, manual runs and scheduled pipelines.

Variables documented on drupal.org control the matrix: OPT_IN_TEST_PREVIOUS_MAJOR, OPT_IN_TEST_PREVIOUS_MINOR, OPT_IN_TEST_NEXT_MINOR, OPT_IN_TEST_NEXT_MAJOR and OPT_IN_TEST_MAX_PHP (value "1" to enable) test other core versions and the newest PHP, while _TARGET_PHP, _TARGET_CORE and _TARGET_DB_TYPE pin one combination, which is useful in scheduled pipelines. PHPUNIT_EXTRA splits the test run. To add Redis or Memcache, override the services key of the job; the before_script and after_script keys are free for the project. Overriding the workflow key means the project stops receiving automatic updates to the workflow include.

Typical use for meridian_collection if it were published on drupal.org: enable OPT_IN_TEST_NEXT_MINOR so the module is tested against the next Drupal minor before it ships, and set the lint jobs to fail the pipeline so coding standard violations block merging. The tests themselves are written as described in Automated testing and the standards in Coding standards and static analysis. Contribution workflow is in Contributing and the Drupal community.

Version notes

  • drush deploy has existed since Drush 10.3; its cache:warm step needs Drupal 11.2 or later. Drush 13 serves Drupal 10.2+ and 11, Drush 14 serves 11.3+ and 12.

  • Drush 13 removed drush.services.yml; commands use attributes, so custom deploy tooling written for Drush 12 needs porting.

  • Updating from the UI (11.2), uploading modules (10.4/11.0) and the Update Manager flow are gone, so every pipeline is Composer-based. Drupal 12 requires PHP 8.5 and is not installable on 11.2 or earlier; test it in CI with the template variable OPT_IN_TEST_NEXT_MAJOR.

  • The Standard profile no longer creates the Article and Page types (11.4), so CI installs from configuration or a recipe, not from a profile that happens to create content types.

  • Windows production hosting is deprecated (11.3) and IIS support was removed in 11.0; pipelines target Linux.