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.