For a long time, my WordPress deployment workflow was embarrassingly manual. FTP into the server, drag and drop the plugin folder, cross my fingers, and hope nothing broke. I’ve seen that workflow survive at shops handling tens of thousands of dollars a month in revenue. It works, until it doesn’t. One misplaced file, one forgotten npm run build, one accidental overwrite of a production config, and your Monday morning turns into a very bad day.
After joining SeedProd and working on a plugin that’s active on over a million WordPress sites, I had to grow up fast. We can’t afford “oops, I forgot to push the compiled assets.” At scale, that kind of mistake means support tickets from thousands of users before your coffee is ready. So over the past few years I’ve built and refined a GitHub Actions-based deployment pipeline that handles everything — linting, testing, building frontend assets, and shipping to both WordPress.org SVN and staging servers. Here’s exactly how I do it.
Why CI/CD Matters for WordPress (Yes, Even Your Plugin)
The WordPress community has been slow to adopt CI/CD compared to other ecosystems. Part of that is cultural — WordPress powers a huge chunk of the web because it’s accessible, and accessibility often means “just FTP it.” But if you’re building anything beyond a simple brochure site — a plugin with a Vue.js-powered settings page, a theme with a webpack build step, a client site with database migrations — manual deployments are a liability.
Here’s what a proper WordPress GitHub Actions deployment pipeline actually buys you:
- Repeatability. The same steps run every single time. No “I forgot to run the linter locally.”
- Auditability. Every deployment is tied to a commit, a pull request, a developer. You know exactly what shipped and when.
- Safety. Tests have to pass before anything gets deployed. PHPCS has to be happy. The build can’t fail silently.
- Speed. Once the pipeline is set up, releasing a new version of a plugin takes one git push and a cup of coffee.
The upfront investment is maybe a few hours. The long-term payoff is enormous. Let me walk you through the full setup.
Repository Structure Before We Write Any YAML
Good CI/CD starts with a clean repo structure. For a typical WordPress plugin with a frontend build step, I expect something like this:
my-plugin/
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
├── src/
│ └── (Vue.js / JS source files)
├── includes/
│ └── (PHP classes)
├── tests/
│ └── (PHPUnit test files)
├── my-plugin.php
├── composer.json
├── package.json
├── phpcs.xml
└── phpunit.xml
The .github/workflows/ directory is where GitHub Actions looks for workflow definitions. I split CI (lint + test) from deployment into separate files — it keeps things readable and lets me trigger them independently.
Running PHPCS and PHPUnit in CI
Before anything gets deployed, the code needs to be clean. I run PHP_CodeSniffer against the WordPress Coding Standards and PHPUnit for unit tests on every push and every pull request. Here’s the CI workflow:
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
phpcs:
name: PHP Coding Standards
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: composer, phpcs
- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHPCS
run: vendor/bin/phpcs --standard=phpcs.xml
phpunit:
name: PHPUnit Tests
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wordpress_test
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
ports:
- 3306:3306
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: composer
- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress
- name: Install WordPress test suite
run: bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1 latest
- name: Run PHPUnit
run: vendor/bin/phpunit
A few things important here. I use the excellent shivammathur/setup-php action — it’s the most reliable way to get a specific PHP version in GitHub Actions and it handles extensions cleanly. The MySQL service container gives PHPUnit a real database to work against, which matters if your tests touch anything database-related. And bin/install-wp-tests.sh is the standard WordPress test bootstrap script — you can grab it from the wp-cli/scaffold-command package.
Building Frontend Assets in the Pipeline
At SeedProd, and on most of my plugin projects these days, there’s a Vue.js-powered UI that needs to be compiled before deployment. Running npm run build locally and committing the built assets is a bad habit — it leads to merge conflicts in dist/ files and makes PRs noisy. Better to build in CI and deploy the artifacts.
Here’s how I add a frontend build job to the workflow:
build-assets:
name: Build Frontend Assets
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install npm dependencies
run: npm ci
- name: Build assets
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: built-assets
path: dist/
retention-days: 1
Using npm ci instead of npm install is intentional — it installs exactly what’s in package-lock.json and fails if there’s a mismatch. The built assets are uploaded as a GitHub Actions artifact so the deploy job can download them without re-running the build. The cache: 'npm' option on setup-node caches the ~/.npm directory between runs, which speeds things up considerably on subsequent pushes.
Deploying to WordPress.org SVN via GitHub Actions
WordPress.org still uses SVN. It feels like time travel every time I interact with it, but the process is well-understood and there’s a great community action that handles the heavy lifting. I use the 10up/action-wordpress-plugin-deploy action for releases to the WordPress.org plugin repository.
I trigger this only on a version tag push — I never want to accidentally push to the SVN trunk from a feature branch:
name: Deploy to WordPress.org
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
jobs:
deploy-wporg:
name: Deploy to WordPress.org SVN
runs-on: ubuntu-latest
needs: [ phpcs, phpunit, build-assets ]
steps:
- uses: actions/checkout@v4
- name: Download built assets
uses: actions/download-artifact@v4
with:
name: built-assets
path: dist/
- name: Deploy to WordPress.org
uses: 10up/action-wordpress-plugin-deploy@stable
env:
SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}
SVN_USERNAME: ${{ secrets.SVN_USERNAME }}
SLUG: my-plugin-slug
The needs array is critical — it means this job won’t even start unless PHPCS, PHPUnit, and the asset build all pass. If your tests are broken, nothing ships. That’s the whole point.
The 10up/action-wordpress-plugin-deploy action reads a .distignore file in your repo root to know which files to exclude from the SVN commit. Make sure you’re excluding src/, node_modules/, tests/, .github/, and anything else that doesn’t belong in the distributed plugin.
Deploying to a Staging Server via rsync and SSH
For custom themes and client plugins that don’t go to WordPress.org, I deploy to staging (and production) via rsync over SSH. It’s fast, it’s reliable, and it gives you fine-grained control over what gets synced.
deploy-staging:
name: Deploy to Staging Server
runs-on: ubuntu-latest
needs: [ phpcs, phpunit, build-assets ]
if: github.ref == 'refs/heads/develop'
steps:
- uses: actions/checkout@v4
- name: Download built assets
uses: actions/download-artifact@v4
with:
name: built-assets
path: dist/
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.STAGING_HOST }} >> ~/.ssh/known_hosts
- name: Deploy via rsync
run: |
rsync -avz --delete
--exclude='.git'
--exclude='node_modules'
--exclude='src'
--exclude='tests'
--exclude='.github'
./ ${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }}:${{ secrets.STAGING_PATH }}/
- name: Run post-deploy WP-CLI commands
run: |
ssh ${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }} << 'EOF'
cd ${{ secrets.STAGING_PATH }}
wp cache flush
wp plugin activate my-plugin --path=/var/www/html
wp option update my_plugin_version "${{ github.ref_name }}" --path=/var/www/html
EOF
The --delete flag on rsync removes files on the remote that no longer exist in the source. That’s usually what you want for a plugin directory — you don’t want old compiled assets hanging around. Be thoughtful about this on theme deployments where the remote might have user-uploaded files in unexpected places.
WP-CLI Commands in Deployment Scripts
WP-CLI is where your deployment pipeline goes from good to great. After files are synced, there’s usually a handful of WordPress-specific tasks to run. Here are the ones I reach for most often:
# Flush all caches after deploying
wp cache flush
# Enable maintenance mode before a risky migration
wp maintenance-mode activate
# Run any pending database upgrades
wp core update-db
# Run a custom migration command (if you've registered one)
wp my-plugin migrate --version=2.5.0
# Clear the object cache (useful with Redis/Memcached)
wp cache flush --user=www-data
# Disable maintenance mode when you're done
wp maintenance-mode deactivate
# Verify the plugin is active and healthy
wp plugin status my-plugin
The maintenance mode commands deserve special attention. For major releases that involve schema changes or data migrations, I always wrap the WP-CLI migration in a maintenance mode block. Users get a clean “back shortly” page instead of a broken experience while migrations run. For minor releases, I skip it — the few seconds of file sync don’t warrant the user-facing interruption.
Managing Secrets Properly
Never hardcode credentials in your workflow YAML. GitHub Actions has a Secrets store built in — go to your repo’s Settings > Secrets and variables > Actions and add everything sensitive there. In my staging deployment above, I use:
STAGING_SSH_KEY— the private key for the deployment user on the staging serverSTAGING_HOST— the server hostname or IPSTAGING_USER— the SSH user (I use a dedicateddeployuser with minimal permissions)STAGING_PATH— the absolute path to the plugin/theme directory on the serverSVN_USERNAMEandSVN_PASSWORD— WordPress.org credentials for SVN deploys
For environment-specific configuration (database credentials, API keys, etc.), I do not manage those through the deployment pipeline. Those live in a wp-config.php or .env file on the server itself that is never touched by the deploy. The pipeline only ships code, never config.
One pattern I’ve settled on for the SSH key: create a dedicated deploy system user on each server with its own keypair, and restrict that user’s shell to rsync and a small set of WP-CLI commands via authorized_keys command restrictions. It’s more setup up front but it means a compromised GitHub secret can’t do arbitrary damage to your server.
The Complete deploy.yml
Here’s a consolidated, production-ready workflow file that ties everything together. This is close to what I actually use for plugin projects:
name: Deploy
on:
push:
branches: [ develop ]
tags:
- '[0-9]+.[0-9]+.[0-9]+'
jobs:
phpcs:
name: PHP Coding Standards
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: composer
- run: composer install --prefer-dist --no-progress
- run: vendor/bin/phpcs --standard=phpcs.xml
phpunit:
name: PHPUnit Tests
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wordpress_test
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
ports:
- 3306:3306
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: composer
- run: composer install --prefer-dist --no-progress
- run: bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1 latest
- run: vendor/bin/phpunit
build-assets:
name: Build Frontend Assets
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: built-assets
path: dist/
retention-days: 1
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: [ phpcs, phpunit, build-assets ]
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: built-assets
path: dist/
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.STAGING_HOST }} >> ~/.ssh/known_hosts
- name: rsync files
run: |
rsync -avz --delete
--exclude='.git' --exclude='node_modules'
--exclude='src' --exclude='tests' --exclude='.github'
./ ${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }}:${{ secrets.STAGING_PATH }}/
- name: Post-deploy tasks
run: |
ssh ${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }} << 'EOF'
wp cache flush --path=/var/www/html
wp plugin status my-plugin --path=/var/www/html
EOF
deploy-wporg:
name: Deploy to WordPress.org
runs-on: ubuntu-latest
needs: [ phpcs, phpunit, build-assets ]
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: built-assets
path: dist/
- uses: 10up/action-wordpress-plugin-deploy@stable
env:
SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }}
SVN_USERNAME: ${{ secrets.SVN_USERNAME }}
SLUG: my-plugin-slug
Lessons Learned from Automating Plugin Releases
A few things I wish someone had told me when I started down this road:
Tag triggers are your friend, branch triggers can bite you. Early on I had deployments triggering on every push to main. That meant a typo fix in a README could kick off a WordPress.org SVN deploy. Now I’m strict: WordPress.org only deploys on a version tag, staging deploys on develop pushes, production deploys on main with a manual approval step via GitHub Environments.
The .distignore file is easy to forget and painful when you do. I’ve shipped node_modules to SVN exactly once. The resulting plugin zip was 40MB and I got a very polite but firm email from the WordPress.org plugin team. Add a .distignore file and keep it updated whenever you add new tooling to the repo.
Cache flushes are not optional. WordPress object cache, opcode cache, CDN cache — on a busy site, deploying without flushing caches means users might see inconsistent states for minutes or hours. I always run wp cache flush post-deploy, and for sites behind Cloudflare, I have an additional step that hits the Cloudflare API to purge the cache zone.
Test your pipeline on a throwaway branch first. The first time you set up a deployment workflow, push to a feature branch and watch the Actions tab. Don’t push to main and then discover your SSH key secret name has a typo. I’ve done this. It’s not fun at 11pm.
WP-CLI’s --path flag will save you headaches. On servers running multiple WordPress installs, always pass --path explicitly in your SSH commands. Relying on the working directory to be right is asking for trouble.
Setting all this up took me a weekend the first time. Now it takes me maybe two hours to wire up a new plugin project from scratch because I’ve got the patterns memorized. Release days went from stressful to boring. Boring is good when you’re pushing code to a million sites.