14+ years building on WordPress / Replies in under 5 hours
Development 8 min read · Updated October 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/ssh-action@v1.0.3`, 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) and 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/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_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/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.

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. You work directly with me, and replies typically arrive within 5 hours.

Work with me →