Skip to main content
back arrow All Insights

Setting Up a Full CI/CD Pipeline for Drupal with GitHub Actions

Posted By: Harsh Dudharejiya |

Image
drupal ci/cd

A solid CI/CD pipeline is essential to ensuring your Drupal code is clean, tested, and automatically deployed. This guide walks you through how to set up a full CI/CD workflow using GitHub Actions for a Drupal project. It includes automated code style checking, test execution, and deployment via SSH — all while keeping deployment variables modular and organized in a separate shell config file.

GitHub Actions Workflow in Action

Right after setting up your CI/CD pipeline, here's how it looks in GitHub:

Successful Run

CI/CD Objectives

  • Run PHPCS using Drupal Coder + Slevomat coding standards.
  • Run PHPUnit for Unit, Kernel, and Functional test folders.
  • Inject and clean temporary DB config inside settings.php.
  • Deploy automatically to a remote server using SSH and git pull.
  • Store deployment credentials in an external shell script (deploy.config.sh).

File & Directory Structure

your-drupal-project/
├── .github/
│   └── workflows/
│       └── main.yml        # <- GitHub Actions CI/CD workflow lives here
├── deploy/
│   └── deploy.config.sh    # <- SSH credentials and server info stored here
├── web/                    # <- Drupal web root
│   └── sites/
│       └── default/
│           └── settings.php

Step-by-Step CI Job

This job, named drupal-ci, ensures all code is clean and tested before deployment.

1. Create .github/workflows/main.yml

This is where the full CI/CD workflow configuration goes. Create this file in your repository.

mkdir -p .github/workflows
touch .github/workflows/main.yml

Paste the full CI/CD YAML workflow here. (The full config is included at the end of this blog.)

2. Setup MySQL Service

services:
 mysql:
   image: mysql:5.7
   env:
     MYSQL_DATABASE: drupal11
     MYSQL_USER: drupal11
     MYSQL_PASSWORD: drupal11
     MYSQL_ROOT_PASSWORD: root

This creates a containerized database to be used by Drupal during CI.

3. Setup PHP and Install Dependencies

- uses: shivammathur/setup-php@v2
- run: composer install --no-interaction --prefer-dist
- run: composer require drupal/coder --dev
- run: composer require --dev slevomat/coding-standard

This installs PHP, Composer dependencies, and the coding standards.

4. Configure and Run PHPCS

- run: |
   vendor/bin/phpcs --config-set installed_paths vendor/drupal/coder/coder_sniffer,vendor/slevomat/coding-standard
   vendor/bin/phpcs --config-set default_standard Drupal
   vendor/bin/phpcs --standard=Drupal web/modules/custom

This allows Drush and PHPUnit to access the temporary MySQL container.

5. Inject Temporary DB Config

- run: |
   echo "// >>> CI TEMP DB CONFIG START >>>" >> web/sites/default/settings.php
   echo "\$databases['default']['default'] = array (...);" >> web/sites/default/settings.php
   echo "// <<< CI TEMP DB CONFIG END <<<" >> web/sites/default/settings.php

This allows Drush and PHPUnit to access the temporary MySQL container.

6. Run Drush & PHPUnit

- run: vendor/bin/drush status
- run: vendor/bin/drush cr

Why drush cr here?

In the CI phase, this ensures that your testing environment has an up-to-date Drupal cache. This is especially important when new services, plugins, or route definitions are added during development.

- run: |
   for folder in Unit Kernel Functional; do
     find web/modules/custom -type d -name "$folder" -exec vendor/bin/phpunit {} +;
   done

7. Clean Up DB Config

- run: |
   sed -i '/\/\/ >>> CI TEMP DB CONFIG START >>>/,/\/\/ <<< CI TEMP DB CONFIG END <<</d' web/sites/default/settings.php

This removes injected credentials before deployment.

Deployment with SSH

The deployment step only runs after the drupal-ci job passes and only for the develop branch.

1. Create deploy/deploy.config.sh

mkdir deploy
touch deploy/deploy.config.sh

Example contents of deploy.config.sh:

#!/bin/bash
export SSH_USER="your-remote-username"
export SSH_HOST="your-remote-host"
export SSH_PORT="your-remote-ssh-port"
export SSH_PRIVATE_KEY="${{ secrets.REMOTE_PRIVATE_KEY }}"

This file is sourced in the deployment step to load variables. You can choose to store the key inline, or inject it securely.

2. SSH Setup & Deployment Logic

- run: |
   source ./deploy/deploy.config.sh
   mkdir -p ~/.ssh
   echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
   chmod 600 ~/.ssh/id_ed25519
   ssh-keyscan -p "$SSH_PORT" "$SSH_HOST" >> ~/.ssh/known_hosts
   echo -e "Host hostinger\n  HostName $SSH_HOST\n  Port $SSH_PORT\n  User $SSH_USER\n  IdentityFile ~/.ssh/id_ed25519\n  StrictHostKeyChecking no" >> ~/.ssh/config

Then deploy using ssh and pull latest code:

ssh hostinger <<'EOF'
 cd public_html/dev || exit 1
 git pull origin develop
 composer2 install --no-dev --no-interaction
 php vendor/bin/drush cr
EOF

Why drush cr here?
Running drush cr during deployment ensures Drupal picks up any new plugins, service definitions, routes, and configuration changes from the freshly deployed code. This step is essential for stability and avoiding odd runtime issues.

Final YAML: .github/workflows/main.yml

name: Drupal CI/CD Pipeline

on:
  push:
    branches:
      - develop
  pull_request:
    branches:
      - develop

jobs:
  drupal-ci:
    name: Drupal CI Pipeline
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:5.7
        env:
          MYSQL_DATABASE: drupal11
          MYSQL_USER: drupal11
          MYSQL_PASSWORD: drupal11
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping --silent"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, xml, ctype, iconv, intl, pdo, curl, dom, json
          tools: composer:v2

      - name: Cache Composer dependencies
        uses: actions/cache@v3
        with:
          path: |
            ~/.composer/cache
            vendor
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            ${{ runner.os }}-composer-

      - name: Install Composer dependencies
        run: composer install --no-interaction --prefer-dist

      - name: Install Drupal Coder
        run: composer require drupal/coder --dev

      - name: Install Slevomat Coding Standard
        run: composer require --dev slevomat/coding-standard

      - name: Setup PHPCS for Drupal and Slevomat
        run: |
          vendor/bin/phpcs --config-set installed_paths \
            vendor/drupal/coder/coder_sniffer,vendor/slevomat/coding-standard
          vendor/bin/phpcs --config-set default_standard Drupal
          vendor/bin/phpcs -i

      - name: Run PHPCS on custom modules
        run: |
          vendor/bin/phpcs --standard=Drupal --extensions=php,module,inc,install,test,profile,theme web/modules/custom

      - name: Inject DB settings into settings.php
        run: |
          echo "" >> web/sites/default/settings.php
          echo "// CI TEMP DB CONFIG START" >> web/sites/default/settings.php
          echo "\$databases['default']['default'] = array (" >> web/sites/default/settings.php
          echo "  'database' => 'drupal11'," >> web/sites/default/settings.php
          echo "  'username' => 'drupal11'," >> web/sites/default/settings.php
          echo "  'password' => 'drupal11'," >> web/sites/default/settings.php
          echo "  'prefix' => ''," >> web/sites/default/settings.php
          echo "  'host' => '127.0.0.1'," >> web/sites/default/settings.php
          echo "  'port' => '3306'," >> web/sites/default/settings.php
          echo "  'isolation_level' => 'READ COMMITTED'," >> web/sites/default/settings.php
          echo "  'driver' => 'mysql'," >> web/sites/default/settings.php
          echo "  'namespace' => 'Drupal\\\\mysql\\\\Driver\\\\Database\\\\mysql'," >> web/sites/default/settings.php
          echo "  'autoload' => 'core/modules/mysql/src/Driver/Database/mysql/'," >> web/sites/default/settings.php
          echo ");" >> web/sites/default/settings.php
          echo "// CI TEMP DB CONFIG END" >> web/sites/default/settings.php

      - name: Run Drush status
        run: vendor/bin/drush status

      - name: Drush Cache Rebuild
        run: vendor/bin/drush cr

      - name: Run PHPUnit Tests (Custom Modules Only)
        run: |
          for folder in Unit Kernel Functional; do
            find web/modules/custom -type d -name "$folder" -exec vendor/bin/phpunit {} +;
          done

      - name: Remove DB settings from settings.php
        run: |
          sed -i '/\/\/ CI TEMP DB CONFIG START/,/\/\/ CI TEMP DB CONFIG END/d' web/sites/default/settings.php

  deploy:
    name: SSH Deploy
    runs-on: ubuntu-latest
    needs: drupal-ci
    if: github.ref == 'refs/heads/develop'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Load Deployment Config
        run: |
          source ./deploy/deploy.config.sh
          echo "Loaded deployment configuration"

      - name: Setup SSH Key and Config
        run: |
          source ./deploy/deploy.config.sh

          mkdir -p ~/.ssh
          echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519

          if [ -n "$SSH_PORT" ]; then
            ssh-keyscan -p "$SSH_PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
          else
            ssh-keyscan "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
          fi

          echo -e "Host remote\n  HostName $SSH_HOST\n  Port $SSH_PORT\n  User $SSH_USER\n  IdentityFile ~/.ssh/id_ed25519\n  StrictHostKeyChecking no" >> ~/.ssh/config

      - name: SSH into Remote and deploy
        run: |
          ssh remote <<'EOF'
            cd /path/to/your/project || exit 1
            echo "Pulling latest code from develop..."
            git pull origin develop

            echo "Installing composer dependencies..."
            composer2 install --no-dev --no-interaction

            echo "Rebuilding Drupal cache..."
            php vendor/bin/drush cr
          EOF

Summary

This pipeline ensures:

  • Custom code follows Drupal and Slevomat standards.
  • Tests are automatically executed.
  • Temporary DB settings are injected and removed.
  • Drupal caches are rebuilt both in CI and during deployment.
  • Secure deployment happens via SSH using a modular shell config.

By modularizing variables into a deploy.config.sh file and separating CI/CD logic into GitHub Actions, your Drupal workflow becomes maintainable, scalable, and fully automated.

 

Let's Connect and Build Something Great Together