Skip to main content
{ blog }

Building a CI/CD Pipeline
for Drupal: A Practical Guide

DrupalDevOps
September 202413 min

Most Drupal shops still deploy with a combination of Drush commands, manual file transfers, and institutional memory. This works — until it doesn't. A misconfigured config import, a forgotten cache rebuild, a deployment that lands on production before it's been tested on staging: these are predictable failures from an unpredictable process.

This post walks through building a production-ready CI/CD pipeline for Drupal using GitHub Actions. By the end, every push to your main branch triggers an automated build, test, and deployment sequence that's repeatable, auditable, and reversible.

Why manual Drupal deployments are a liability

Manual deployments fail in specific, predictable ways:

  • Someone forgets to run drush updb after deploying a module update
  • Config is exported from the wrong environment and overwrites production settings
  • A deployment happens during business hours because 'it'll only take a minute'
  • Nobody remembers which version is actually on staging because deployments weren't documented
  • A rollback takes 45 minutes because there's no defined process

A CI/CD pipeline makes deployments boring and safe. The same sequence runs every time, in the same order, with the same checks.

What a Drupal CI/CD pipeline needs

Drupal deployments have specific requirements that generic pipeline templates don't handle well:

  • Composer install — dependencies must be resolved and vendored correctly
  • Config export/import — configuration changes must be tracked in version control and applied on deployment
  • Database updatesdrush updb must run after code deployment
  • Cache rebuilddrush cr after updates
  • Config importdrush cim to apply any configuration changes
  • Deployment hooks — environment-specific configuration via settings files

GitHub Actions: step-by-step setup

Create .github/workflows/deploy.yml in your repository. Here's a production-ready configuration:

name: Deploy Drupal

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: drupal
        options: --health-cmd="mysqladmin ping" --health-interval=10s

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          extensions: mbstring, xml, gd, pdo_mysql

      - name: Install dependencies
        run: composer install --no-dev --optimize-autoloader

      - name: Run PHPUnit tests
        run: ./vendor/bin/phpunit --testsuite=unit

      - name: Check coding standards
        run: ./vendor/bin/phpcs --standard=Drupal web/modules/custom

  deploy-staging:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to staging
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.STAGING_USER }}
          key: ${{ secrets.STAGING_SSH_KEY }}
          script: |
            cd /var/www/staging
            git pull origin main
            composer install --no-dev --optimize-autoloader
            drush updb -y
            drush cim -y
            drush cr
            drush deploy:hook

Handling config splits between environments

Production and staging often need different configuration — different base URLs, different logging levels, different API keys. The Config Split module handles this cleanly: you define a split per environment and store environment-specific config in a separate directory that doesn't get imported on other environments.

Store your deployment-specific settings in settings.php and settings.local.php (git-ignored), and use environment variables for secrets. Never commit credentials or environment-specific values to version control.

Automated testing: what to cover

A Drupal CI pipeline should run at minimum:

  • PHPUnit unit tests — fast, test business logic in isolation. Should run on every PR.
  • PHPUnit kernel tests — test integration with Drupal's kernel, including database. Slower but valuable for custom modules.
  • PHP CodeSniffer — enforce Drupal coding standards automatically. Catches issues before code review.
  • Config validationdrush config:status confirms config in the repo matches what's in the database after import.

Deployment targets: Acquia, Pantheon, and self-managed

The pipeline above uses SSH deployment. Most managed Drupal hosts have native CI/CD integrations worth using instead:

  • Acquia — use the Acquia Cloud API or Acquia Pipelines. Native config import and cache rebuild hooks are built into the deployment process.
  • Pantheon — Terminus CLI integrates directly with GitHub Actions. Pantheon's multidev environments give you a full Drupal environment per branch.
  • Self-managed on AWS/GCP — the SSH approach above works well, or use CodeDeploy for more sophisticated blue/green deployments.

{ work with us }

Need help with
this in practice?

Tell us about your project. We’ll give you an honest assessment — no commitment required.

Start a conversation → Our services

Ready to start your next project?

Tell us what you're building. We'll respond within one business day with an honest assessment — no commitment required.