14+ years building on WordPress / Replies in under 5 hours
Development 8 min read · Updated July 2026

How to Set Up a CI/CD Pipeline for WordPress Development

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
How to Set Up a CI/CD Pipeline for WordPress Development (Step-by-Step Guide)
TL;DR

A CI/CD pipeline automates testing and deployment for custom WordPress themes and plugins — on every push to your main branch, code checks run and files deploy to the server, with no manual FTP steps. For GitHub Actions (the most common choice): create `.github/workflows/deploy.yml`, use `actions/checkout@v4` and `appleboy/[email protected]`, store `SSH_HOST`/`SSH_USER`/`SSH_PRIVATE_KEY` as repository secrets, and have the SSH script `git pull` and run `wp cache flush` on the server. Use SSH not FTP — FTP is unencrypted. For automated testing: add a `lint` job using `shivammathur/setup-php@v2` with PHPCS and the WordPress Coding Standards (`wp-coding-standards/wpcs`) before the deploy job; add `needs: lint` to the deploy job so deployment only runs if code checks pass. For local dev: use Docker Compose with `mysql:8.0` (MySQL 5.7 reached EOL in October 2024) — omit the deprecated `version:` key. Use a three-branch model: `feature/*` → `develop` (deploys to staging) → `main` (deploys to production). Track only custom themes and plugins in git — never WordPress core, `wp-config.php`, or the `vendor/` directory.

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_keys on 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.

Frequently asked questions

A CI/CD (Continuous Integration / Continuous Deployment) pipeline automates the process of testing and deploying custom WordPress themes and plugins. Instead of manually uploading changed files via FTP after every edit, you push a commit to git and the pipeline runs automatically: it checks your code against WordPress Coding Standards (PHPCS), then deploys the updated files to your server via SSH. CI (Continuous Integration) refers to the testing step; CD (Continuous Deployment) refers to the automated deployment. The result is faster, more consistent deploys with less risk of human error.

SSH. FTP transmits credentials and file contents in plaintext — anyone monitoring the network connection can read both. SSH encrypts the connection end-to-end and uses key-based authentication with no password transmitted at all. In GitHub Actions, use the `appleboy/ssh-action` to run commands on your server over SSH. Generate an ed25519 key pair with `ssh-keygen -t ed25519`, add the public key to `~/.ssh/authorized_keys` on the server, and store the private key as a GitHub secret (`SSH_PRIVATE_KEY`). FTP-based deploy actions are still common in older tutorials but should be avoided for any production WordPress site.

Track only your custom code: the theme and plugin directories under `wp-content/themes/` and `wp-content/plugins/`. Do not commit WordPress core files (`wp-admin/`, `wp-includes/`, `wp-*.php`), `wp-config.php` (contains database credentials), the `wp-content/uploads/` directory (user-uploaded media), or your `vendor/` directory (Composer dependencies — install these on the server in your CI/CD script). Commit `composer.json` and `composer.lock` so the pipeline can install the exact dependency versions. Use a `.gitignore` to exclude everything else.

Use PHP_CodeSniffer (PHPCS) with the WordPress Coding Standards (WPCS). In your GitHub Actions workflow, add a lint job using `shivammathur/setup-php@v2` to set up PHP 8.2 and PHPCS, then run `composer global require wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer` and call `phpcs --standard=WordPress your-theme-directory/`. Add `needs: lint` to your deploy job so the pipeline stops if any coding standards violations are found. WPCS checks for WordPress-specific security patterns: proper escaping functions (`esc_html()`, `esc_url()`), sanitisation, and direct database query usage.

If you're only updating content and not touching theme or plugin code, no — a CI/CD pipeline is for developers making code changes. If you're building or maintaining a custom WordPress theme or plugin, even on a small site, a basic pipeline pays off quickly: it prevents the 'I forgot to flush cache' class of errors, catches PHP syntax problems before they break the site, and gives you a consistent deploy process that works the same every time. A GitHub Actions pipeline with SSH deployment takes about 30 minutes to set up and runs for free on GitHub's free tier (2,000 minutes/month).

Both achieve the same result — automated testing and deployment triggered by a git push — with different syntax and where your code is hosted. GitHub Actions uses `.github/workflows/*.yml` files and integrates with GitHub repositories; the free tier includes 2,000 minutes/month. GitLab CI/CD uses a single `.gitlab-ci.yml` file and integrates with GitLab repositories; the free tier includes 400 minutes/month on GitLab.com (unlimited for self-hosted GitLab). The YAML structure differs: GitHub Actions organises around `jobs` with `steps`; GitLab organises around `stages` and `jobs` within stages. Both support SSH deployment, environment variables (secrets), and matrix testing. Choose based on where your repository already lives.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →