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/contribandweb/themes/contrib. It is reproduced fromcomposer.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
Source: Composer CLI: install
What the flags do:
-
composer installreadscomposer.lock, so the artefact contains exactly the versions that were tested. Never runcomposer updatein a deployment. -
--no-devskipsrequire-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-autoloaderbuilds a class map;--classmap-authoritativemakes 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-distdownloads 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
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
Source: Drush: Deploy command for Drupal
Notes on the script:
-
--yesis required because CI has no terminal; without itupdatedbandconfig:importask for confirmation and the job hangs or aborts. -
set -euo pipefailstops on the first failure. A failedconfig:importleaves the site in maintenance mode, which is the safe state; do not turn maintenance mode off in a trap. -
config:statusat 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:importaborts 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 withconfig: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:statusand 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-configcreates 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
Source: Drush: config:import
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.
updatedbchanges 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:importchanges 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
currentsymlink, and reload PHP-FPM (or setopcache.revalidate_path) so that stale paths are not cached; see OPcache, JIT and performance. -
Cache.
cache:rebuildclears every cache backend, so the first requests after a deploy are slow. Warm the cache (thecache:warmstep 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. Rundrush deployonce, 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_KEYis defined separately forstagingandproduction, so staging jobs can never read the production key. Secrets are not available to workflows triggered from forks, which is the intended protection. -
phpunitfor a project inweb/modules/customneedsSIMPLETEST_DBandSIMPLETEST_BASE_URLfor 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-devfor the artefact. The alternative is two jobs, which keeps the test job output untouched. -
StrictHostKeyChecking=accept-newtrusts 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
Source: GitLab Docs: Environments
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 |
|
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 |
|
Periodic sanitised copy of production |
Production |
Live site |
|
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_saltand 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) andcomposer configcalls echo values into job logs. -
The deploy user on the server should own only the release directory and be unable to use
sudofor 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.phpis set to mode440or444after the deployment. -
Keep a development tool out of the artefact with
--no-dev, and check thatweb/core/tests, the Markdown readme files and the example.php.distfiles undersites/defaultare not reachable over HTTP. -
Run
composer auditin 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 is in |
Import fails with a missing module or theme |
A module listed in |
"Site UUID in source storage does not match the target" |
Staging was installed from scratch. Restore a database copy instead of overriding the UUID. |
|
No |
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 |
Everyone is logged out after deploy |
|
Site stays in maintenance mode |
A step failed before the final |
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 |
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
Source: GitLab Templates: Set up
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 deployhas existed since Drush 10.3; itscache:warmstep 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.