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/ssh-action@v1.0.3
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/ssh-action@v1.0.3
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/ssh-action@v1.0.3
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/ssh-action@v1.0.3
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, since 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.


