A CI/CD pipeline automates what you’d otherwise do by hand: running code checks, and copying your theme or plugin files to the server every time you push a commit. Without it, every deployment is a manual sequence of steps that can be skipped, done out of order, or done differently each time. With it, a push to the main branch triggers the same process every time — tests run, then the code ships if they pass.
This guide focuses on what works for custom WordPress themes and plugins: GitHub Actions as the primary CI/CD tool (it’s where most independent WordPress developers live), with a GitLab CI/CD alternative, a local Docker setup for development, and PHP code standards checks. If you’re building a custom theme from scratch and want to understand the code structure that goes into git, see the custom WordPress theme development guide first.
What to track in git — and what to leave out
Before configuring any pipeline, decide what belongs in the repository. The correct answer for almost every WordPress project: track your custom code only, not WordPress core.
A typical structure:
wordpress-project/
├── wp-content/
│ ├── themes/
│ │ └── my-custom-theme/
│ └── plugins/
│ └── my-custom-plugin/
├── .gitignore
└── composer.json
Your .gitignore should exclude WordPress core, uploads, and environment-specific files:
# WordPress core — managed by server or WP CLI, not git
wp-admin/
wp-includes/
wp-*.php
index.php
xmlrpc.php
# Environment config — never commit this
wp-config.php
.env
# Generated content
wp-content/uploads/
wp-content/cache/
wp-content/upgrade/
# Dependencies — install on server via Composer
vendor/
node_modules/
If your theme or plugin uses Composer for PHP dependencies, commit composer.json and composer.lock but not the vendor/ directory. The pipeline installs dependencies on the server. See the guide to using Composer for WordPress dependencies for the full setup.
Branching strategy
A simple three-branch model works well for most WordPress projects:
- main — production-ready code; pipeline deploys from here to the live server
- develop — integration branch for features in progress; pipeline deploys from here to staging
- feature/your-feature-name — one branch per feature or bug fix; merge into develop when ready
# Create and switch to a feature branch
git checkout -b feature/add-contact-form
# When done, merge into develop
git checkout develop
git merge feature/add-contact-form
# When develop is stable, merge to main for prod deploy
git checkout main
git merge develop
Add branch protection rules in GitHub (Settings → Branches → Branch protection rules) to prevent direct pushes to main and require pull request review before merging.
GitHub Actions: deploy on push via SSH
GitHub Actions is the most straightforward option for WordPress projects already hosted on GitHub. The free tier gives you 2,000 minutes/month, which is more than sufficient for most projects.
Create .github/workflows/deploy.yml in your repository root:
name: Deploy WordPress Theme
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/[email protected]
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/html/wp-content/themes/my-custom-theme
git pull origin main
composer install --no-dev --optimize-autoloader
wp cache flush
Set the required secrets in GitHub (Settings → Secrets and variables → Actions):
- SSH_HOST — your server’s IP address or hostname
- SSH_USER — the SSH user (typically the server user, not root)
- SSH_PRIVATE_KEY — your private key (generate with
ssh-keygen -t ed25519; add the public key to~/.ssh/authorized_keyson the server)
Why SSH instead of FTP: FTP transmits credentials and file contents in plaintext. SSH encrypts the connection and uses key-based authentication with no password to intercept. If you’re deploying to a server you control, SSH is the only approach worth using in 2026.
Separate workflows for staging and production
Extend the above to run different deployments per branch:
name: Deploy WordPress Theme
on:
push:
branches:
- main
- develop
jobs:
deploy-staging:
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: appleboy/[email protected]
with:
host: ${{ secrets.STAGING_SSH_HOST }}
username: ${{ secrets.STAGING_SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/staging/wp-content/themes/my-custom-theme
git pull origin develop
composer install --no-dev --optimize-autoloader
wp cache flush
deploy-production:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: appleboy/[email protected]
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/html/wp-content/themes/my-custom-theme
git pull origin main
composer install --no-dev --optimize-autoloader
wp cache flush
Automated testing: PHP linting and code standards
The deployment step is CI/CD’s most visible part, but the testing step is what makes it worthwhile. Running code quality checks before deploy catches issues before they reach the server.
Add a lint job that runs on every push (not just to main):
name: Test and Deploy
on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
- develop
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: composer, phpcs
- name: Install WordPress Coding Standards
run: |
composer global require wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
- name: Run PHPCS
run: phpcs --standard=WordPress wp-content/themes/my-custom-theme/
deploy:
needs: lint
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: appleboy/[email protected]
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/html/wp-content/themes/my-custom-theme
git pull origin main
composer install --no-dev --optimize-autoloader
wp cache flush
The needs: lint line on the deploy job means deployment only runs if the lint job passes. If PHPCS finds a coding standards violation, the workflow stops and the deploy never runs.
WordPress Coding Standards (WPCS) checks for WordPress-specific patterns: proper use of esc_html() / esc_url() / sanitize_text_field(), direct database queries that should use $wpdb, and other security patterns. It’s a worthwhile addition to any WordPress plugin or theme development workflow.
GitLab CI/CD alternative
If your project is on GitLab, add a .gitlab-ci.yml file to the repository root. The structure is similar — stages, jobs, SSH deployment — but the syntax is different:
stages:
- test
- deploy
variables:
WP_THEME_PATH: /var/www/html/wp-content/themes/my-custom-theme
lint:
stage: test
image: php:8.2-cli
before_script:
- curl -sS https://getcomposer.org/installer | php
- php composer.phar global require wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
- export PATH="$PATH:$HOME/.composer/vendor/bin"
script:
- phpcs --standard=WordPress wp-content/themes/my-custom-theme/
rules:
- if: $CI_COMMIT_BRANCH
deploy-production:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client bash
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '
' > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts
script:
- ssh "$SSH_USER@$SSH_HOST" "
cd $WP_THEME_PATH &&
git pull origin main &&
composer install --no-dev --optimize-autoloader &&
wp cache flush
"
rules:
- if: $CI_COMMIT_BRANCH == "main"
environment:
name: production
Add SSH_HOST, SSH_USER, and SSH_PRIVATE_KEY as CI/CD variables in GitLab (Settings → CI/CD → Variables). Mark SSH_PRIVATE_KEY as protected and masked.
Local development with Docker
Keeping local and server environments in sync prevents “it works on my machine” issues. Docker gives you a reproducible WordPress environment you can spin up with one command.
Create docker-compose.yml in your project root:
services:
wordpress:
image: wordpress:latest
restart: always
ports:
- "8000:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
volumes:
- ./wp-content:/var/www/html/wp-content
depends_on:
- db
db:
image: mysql:8.0
restart: always
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
MYSQL_ROOT_PASSWORD: rootpassword
volumes:
- db_data:/var/lib/mysql
volumes:
db_data:
Start the environment:
docker compose up -d
Visit http://localhost:8000 to complete the WordPress install. Your local wp-content/ directory is mounted directly into the container, so changes to theme or plugin files are reflected immediately without restarting Docker.
Two notes on the configuration: the version: key is deprecated in Docker Compose v2 (included with Docker Desktop since 2022) and should be omitted. The example uses mysql:8.0 — MySQL 5.7 reached end of life in October 2024. For a comparison of Docker against Lando and LocalWP for day-to-day WordPress development, see the guide to setting up a modern local WordPress development environment.
Backup before deploy
A CI/CD pipeline that deploys without a backup step can leave you with a broken site and no quick recovery path. Add a backup step to your SSH deployment script:
script: |
# Backup current theme before updating
cd /var/www/html/wp-content/themes
tar -czf my-custom-theme-backup-$(date +%Y%m%d-%H%M%S).tar.gz my-custom-theme/
# Deploy
cd my-custom-theme
git pull origin main
composer install --no-dev --optimize-autoloader
wp cache flush
# Clean up backups older than 7 days
find /var/www/html/wp-content/themes -name "my-custom-theme-backup-*.tar.gz" -mtime +7 -delete
For database backups, UpdraftPlus with remote storage (S3, Google Drive, or Dropbox) handles this automatically on a schedule — the theme/plugin backup above covers the code side, but a pre-deploy database snapshot is also worth adding for major updates.
Security hygiene for the pipeline: use SSH key authentication (never a password in secrets), rotate keys periodically, and keep the server user running git pull restricted to the minimum filesystem permissions needed — it shouldn’t run as root. For the broader security plugin and hardening setup that complements a CI/CD workflow, see the pre-build WordPress checklist.
For the full tooling picture that surrounds a CI/CD workflow — editors, debugging tools, performance profiling — see the roundup of 5 tools to streamline WordPress development.


