If you’re managing WordPress sites—whether it’s a corporate blog, client project, or SaaS marketing site—you know the deployment process is a tedious, error-prone manual grind. FTP uploads, SSH connections, database migrations, cache clearing. It’s 2024, and you shouldn’t be doing this by hand anymore.
This is where automate WordPress GitHub Actions workflows come in. GitHub Actions lets you build CI/CD pipelines that handle your entire WordPress deployment process automatically—from code pushes to live site updates. No third-party services required. No expensive DevOps tooling. Just version control, automation, and peace of mind.
In this guide, I’ll walk you through setting up a production-grade WordPress deployment pipeline using GitHub Actions. We’ll cover everything from basic setup to advanced scenarios like zero-downtime deployments, automated testing, and database migrations.
Why You Need CI/CD for WordPress
Let’s be honest: WordPress deployments are manual and messy. You’re probably doing something like this:
- Edit theme files locally or in the WordPress editor
- Test on staging (if you have one)
- FTP or SSH into production
- Copy files over manually
- Hope nothing breaks
- Fix issues in production while users watch
- Remember too late that you forgot to update the database
- Restart PHP-FPM and clear caches manually
Every step is a chance for something to go wrong. And because it’s manual, you’re doing it inconsistently—different steps each time, depending on what you remember.
GitHub Actions solves this by making deployments:
– Automated — triggered by code pushes, not manual steps
– Reproducible — same process every time
– Logged — complete audit trail of what happened when
– Reversible — easy to roll back if something breaks
– Testable — catch issues before production
Plus, if you’re already using GitHub for your theme or plugin code (which you should be), you’re not adding a new tool to your stack.
Setting Up Your WordPress Repository Structure
Before we build the Actions workflow, your repo needs proper structure. This is the foundation everything else sits on.
Here’s what a production-ready WordPress repo looks like:
wordpress-site/
├── .github/
│ └── workflows/
│ └── deploy.yml
├── wp-content/
│ ├── themes/
│ │ └── my-theme/
│ ├── plugins/
│ │ └── my-plugins/
│ └── uploads/ (usually gitignored)
├── .gitignore
├── .env.example
├── wp-config.php
├── wp-cli-config.yml
├── composer.json
└── README.md
Your .gitignore should look like this:
# WordPress files
/wp-admin/
/wp-includes/
wp-config.php
/wp-content/uploads/
/wp-content/cache/
/wp-content/backup-*
# Dependencies
/vendor/
node_modules/
.DS_Store
# Environment
.env
.env.local
# IDE
.vscode/
.idea/
# Logs
*.log
Critical point: Don’t commit WordPress core files. You’ll pull them fresh on the server using Composer or a package manager. This keeps your repo lean and focused on your custom code.
Here’s a basic composer.json for managing WordPress and plugins:
{
"name": "example/wordpress-site",
"description": "Production WordPress deployment",
"require": {
"php": ">=7.4",
"roots/wordpress": "^6.4",
"wpackagist-plugin/advanced-custom-fields": "^6.1",
"wpackagist-plugin/jetpack": "^13.0"
},
"repositories": [
{
"type": "composer",
"url": "https://wpackagist.org"
}
]
}
This approach (using Bedrock or similar) is ideal, but if you’re working with a standard WordPress install, focus on version-controlling just your custom themes and plugins.
Creating Your First Deployment Workflow
Now for the main event. Here’s a basic GitHub Actions workflow that deploys your code when you push to the main branch:
Create .github/workflows/deploy.yml:
name: Deploy WordPress
on:
push:
branches: [ main ]
paths:
- 'wp-content/themes/**'
- 'wp-content/plugins/**'
- '.github/workflows/deploy.yml'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/wordpress
git fetch origin
git reset --hard origin/main
wp cache flush
wp rewrite flush
This workflow:
– Triggers when you push to main (and only if theme/plugin files changed)
– Connects to your server via SSH
– Pulls the latest code
– Flushes caches and rewrite rules
But before this works, you need to add secrets to your GitHub repository. Go to Settings → Secrets and variables → Actions, and add:
HOST— your server’s IP or domainSSH_USER— SSH username (ideally not root)SSH_PRIVATE_KEY— your private SSH key (without passphrase for CI/CD)
To generate an SSH key for this purpose:
ssh-keygen -t ed25519 -f github_deploy -C "github-actions" -N ""
cat github_deploy # Copy this to SSH_PRIVATE_KEY secret
ssh-copy-id -i github_deploy.pub [email protected]
Advanced: Testing Before Deployment
The basic workflow above works, but it’s missing something critical: testing. You don’t want bad code going to production.
Here’s an improved workflow that runs tests first:
name: Deploy WordPress (With Tests)
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
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'
extensions: mysql, mbstring, gd
tools: composer, phpcs
- name: Cache Composer dependencies
uses: actions/cache@v3
with:
path: vendor
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --no-progress
- name: Setup WordPress test environment
run: |
mysql -h 127.0.0.1 -u root -proot wordpress_test < wp-config-test.sql
- name: Run PHP CodeSniffer
run: phpcs wp-content/themes/my-theme wp-content/plugins --standard=WordPress
- name: Run PHPUnit tests
run: phpunit
- name: Check for coding standards
run: composer run-script lint
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/wordpress
git fetch origin
git reset --hard origin/main
# Run any database migrations
wp migrate status
wp migrate run
# Clear all caches
wp cache flush
wp rewrite flush
# Restart PHP-FPM
sudo systemctl restart php-fpm
# Slack notification (optional)
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-d '{"text":"WordPress deployed successfully"}'
Key improvements here:
testjob runs first — tests must pass before deployment happens- MySQL service — spins up a test database automatically
- Multiple checks — PHP CodeSniffer, PHPUnit, custom lint scripts
needs: testin deploy job — won’t run unless test succeeds- Only deploys from main branch — protect against accidental deployments
- Database migrations — handled via WP-CLI
- Slack notifications — know when deployments complete
For the test database setup, create wp-config-test.sql:
CREATE DATABASE IF NOT EXISTS wordpress_test;
USE wordpress_test;
-- Create tables from wp_create_tables.sql
-- Or use wp-cli to generate them:
-- wp core install --url=http://localhost --title="Test" --admin_user=admin --admin_password=admin [email protected]
Zero-Downtime Deployments
If your WordPress site has real traffic, pulling code while traffic is flowing can cause issues—brief timeouts, mixed old/new code, database lock problems.
Here’s a strategy for zero-downtime deployments:
name: Zero-Downtime Deploy
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy with maintenance mode
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -e
cd /var/www
# Enable maintenance mode (WordPress sees 503)
wp maintenance-mode activate
# Wait for in-flight requests to finish (up to 30s)
sleep 30
# Pull new code
cd /var/www/wordpress
git fetch origin
git reset --hard origin/main
# Install dependencies
composer install --no-dev --classmap-authoritative
# Run database migrations (must be backward compatible)
wp migrate status
wp migrate run
# Run any custom deployment hooks
wp deploy-hooks run
# Clear caches
wp cache flush --all
wp rewrite flush
# Disable maintenance mode
wp maintenance-mode deactivate
# Health check - verify site responds
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://example.com)
if [ $HTTP_CODE -ne 200 ]; then
echo "Health check failed: $HTTP_CODE"
exit 1
fi
This workflow:
– Activates maintenance mode (403/503 response)
– Waits for existing requests to complete
– Pulls and deploys code
– Runs migrations
– Disables maintenance mode
– Verifies the site is responding
To support this, install a maintenance mode plugin that respects the mode:
wp plugin install wp-maintenance-mode
Managing Environment-Specific Configuration
Here’s the problem: your .env file has production database credentials, but you can’t commit it to GitHub. How do you deploy without it?
Use GitHub Secrets and build your environment dynamically:
name: Deploy with Environment Setup
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy and configure
uses: appleboy/ssh-action@master
env:
DB_HOST: ${{ secrets.DB_HOST }}
DB_NAME: ${{ secrets.DB_NAME }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASS: ${{ secrets.DB_PASSWORD }}
WP_AUTH_KEY: ${{ secrets.WP_AUTH_KEY }}
WP_SECURE_AUTH_KEY: ${{ secrets.WP_SECURE_AUTH_KEY }}
WP_LOGGED_IN_KEY: ${{ secrets.WP_LOGGED_IN_KEY }}
WP_NONCE_KEY: ${{ secrets.WP_NONCE_KEY }}
WP_AUTH_SALT: ${{ secrets.WP_AUTH_SALT }}
WP_SECURE_AUTH_SALT: ${{ secrets.WP_SECURE_AUTH_SALT }}
WP_LOGGED_IN_SALT: ${{ secrets.WP_LOGGED_IN_SALT }}
WP_NONCE_SALT: ${{ secrets.WP_NONCE_SALT }}
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
envs: DB_HOST,DB_NAME,DB_USER,DB_PASS,WP_AUTH_KEY,WP_SECURE_AUTH_KEY,WP_LOGGED_IN_KEY,WP_NONCE_KEY,WP_AUTH_SALT,WP_SECURE_AUTH_SALT,WP_LOGGED_IN_SALT,WP_NONCE_SALT
script: |
cd /var/www/wordpress
git fetch origin
git reset --hard origin/main
# Create wp-config.php from template
cat > wp-config.php << 'EOF'
<?php
define('DB_NAME', '${{ env.DB_NAME }}');
define('DB_USER', '${{ env.DB_USER }}');
define('DB_PASSWORD', '${{ env.DB_PASS }}');
define('DB_HOST', '${{ env.DB_HOST }}');
define('AUTH_KEY', '${{ env.WP_AUTH_KEY }}');
define('SECURE_AUTH_KEY', '${{ env.WP_SECURE_AUTH_KEY }}');
define('LOGGED_IN_KEY', '${{ env.WP_LOGGED_IN_KEY }}');
define('NONCE_KEY', '${{ env.WP_NONCE_KEY }}');
define('AUTH_SALT', '${{ env.WP_AUTH_SALT }}');
define('SECURE_AUTH_SALT', '${{ env.WP_SECURE_AUTH_SALT }}');
define('LOGGED_IN_SALT', '${{ env.WP_LOGGED_IN_SALT }}');
define('NONCE_SALT', '${{ env.WP_NONCE_SALT }}');
define('WP_DEBUG', false);
define('WP_ENV', 'production');
require_once(ABSPATH . 'wp-settings.php');
EOF
# Fix permissions
chmod 600 wp-config.php
chown www-data:www-data wp-config.php
This approach:
– Stores all secrets in GitHub (never committed)
– Passes them to the SSH script via envs
– Generates wp-config.php on the server
– Sets proper file permissions
Generate WordPress salts using the official salt generator, or programmatically:
curl https://api.wordpress.org/secret-key/1.1/salt/ | grep "'[A-Z_]*'" -o | sed "s/'//g" | while read key; do
echo "$key=$(openssl rand -base64 32)"
done
Handling Database Migrations and Schema Changes
Most WordPress deployments include database changes—new custom fields, plugin table updates, etc. These must be applied carefully and consistently.
Here’s a pattern using WP-CLI and a migration system:
Create a migrations/ directory in your repo:
migrations/
├── 2024-01-15-add-custom-post-type.php
├── 2024-01-20-add-user-meta-field.php
└── Migration.php
migrations/Migration.php:
<?php
abstract class Migration {
protected $wpdb;
protected $name;
public function __construct() {
global $wpdb;
$this->wpdb = $wpdb;
$this->name = basename(__FILE__, '.php');
}
public function run() {
$this->up();
$this->recordMigration();
}
public function rollback() {
$this->down();
$this->removeMigrationRecord();
}
protected function recordMigration() {
update_option('wp_migrations_' . $this->name, current_time('mysql'));
}
protected function removeMigrationRecord() {
delete_option('wp_migrations_' . $this->name);
}
protected function hasMigrated() {
return get_option('wp_migrations_' . $this->name);
}
abstract public function up();
abstract public function down();
}
migrations/2024-01-15-add-custom-post-type.php:
<?php
require_once __DIR__ . '/Migration.php';
class AddCustomPostType extends Migration {
public function up() {
// Register custom post type
register_post_type('portfolio', [
'label' => 'Portfolio',
'public' => true,
'rewrite' => ['slug' => 'portfolio'],
'supports' => ['title', 'editor', 'thumbnail'],
]);
// Flush rewrite rules
flush_rewrite_rules();
}
public function down() {
// Unregister post type (optional cleanup)
unregister_post_type('portfolio');
flush_rewrite_rules();
}
}
$migration = new AddCustomPostType();
if (!$migration->hasMigrated()) {
$migration->run();
WP_CLI::success('Migration completed');
} else {
WP_CLI::log('Already migrated');
}
Then in your deployment workflow:
script: |
cd /var/www/wordpress
# Run all pending migrations
for migration in migrations/*.php; do
if [ "$migration" != "migrations/Migration.php" ]; then
wp eval-file "$migration"
fi
done
wp cache flush
This approach gives you:
– Version-controlled schema changes
– Rollback capability
– Idempotent migrations (safe to run multiple times)
– Clear audit trail
Automated Rollback on Failure
If a deployment breaks your site, you need to revert quickly. Here’s a workflow that keeps the previous version and allows instant rollback:
name: Deploy with Rollback Support
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy with rollback backup
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -e
DEPLOY_DIR="/var/www/wordpress"
BACKUP_DIR="/var/www/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# Backup current version
mkdir -p $BACKUP_DIR
cp -r $DEPLOY_DIR $BACKUP_DIR/wordpress_$TIMESTAMP
# Deploy new version
cd $DEPLOY_DIR
git fetch origin
git reset --hard origin/main
composer install --no-dev
wp migrate run
wp cache flush
# If we got here, deployment succeeded
echo $TIMESTAMP > $BACKUP_DIR/last_successful_deploy
# Clean up old backups (keep 5 most recent)
ls -t $BACKUP_DIR | grep "wordpress_" | tail -n +6 | xargs -I {} rm -rf $BACKUP_DIR/{}
To rollback:
ssh [email protected] << 'EOF'
BACKUP_DIR="/var/www/backups"
DEPLOY_DIR="/var/www/wordpress"
LAST_GOOD=$(cat $BACKUP_DIR/last_successful_deploy)
# Restore previous version
cp -r $BACKUP_DIR/wordpress_$LAST_GOOD $DEPLOY_DIR
# Clear caches and restart
cd $DEPLOY_DIR
wp cache flush
sudo systemctl restart php-fpm
echo "Rolled back to $LAST_GOOD"
EOF
Production Checklist and Best Practices
Before you deploy to production with GitHub Actions, verify you have:
| Item | Purpose |
|---|---|
| SSH key without passphrase | CI/CD can’t handle interactive prompts |
| Dedicated deploy user (not root) | Principle of least privilege |
| MySQL user with limited permissions | Shouldn’t have DROP/CREATE TABLE rights everywhere |
| Regular backups before deployment | Insurance policy |
| Monitoring/alerting on deployments | Know when things break immediately |
| Database migration strategy | Can’t just push schema changes live |
| Health checks after deployment | Verify site is actually working |
| Slack/email notifications | Team knows when deploys happen |
| Rollback procedure tested | Don’t discover it doesn’t work during crisis |
| Rate limiting on staging deploys | Don’t overwhelm your server during testing |
Also consider:
- Keep deployments fast — aim for under 2 minutes from push to live
- One feature per deployment — easier to track issues
- Avoid peak traffic times — schedule deployments during low-traffic periods using scheduled workflows
- Monitor for errors post-deployment — set alerts in error tracking (Sentry, etc.)
- Version your plugins and themes — use git tags to match what’s deployed
Deploying to Multiple Environments
Most real projects need staging and production. Here’s a workflow that deploys to different servers based on branch:
name: Multi-Environment Deploy
on:
push:
branches: [ main, staging ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
if: github.ref == 'refs/heads/staging'
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/wordpress-staging
git fetch origin
git reset --hard origin/staging
wp cache flush
- name: Deploy to production
if: github.ref == 'refs/heads/main'
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/wordpress
git fetch origin
git reset --hard origin/main
wp cache flush
# Run health check
curl -f https://example.com/wp-json/wp/v2/pages || exit 1
Workflow:
1. Push to staging branch — deploys to staging server
2. Test on staging
3. Create pull request from staging → main
4. Merge to main — triggers production deployment
This gives you a safe promotion path and keeps staging/production in sync.
Getting Started Today
You don’t need a complex setup to benefit from GitHub Actions. Start with this minimal workflow and expand from there:
name: Simple WordPress Deploy
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/wordpress
git reset --hard origin/main
wp cache flush
Then iterate:
1. Add basic PHP linting
2. Add database backup before deploy
3. Add health checks
4. Add staging environment
5. Add testing
6. Add automated rollback
Each piece makes your deployments safer and faster.
GitHub Actions for WordPress deployments transforms your workflow from error-prone manual steps to automated, tested, logged processes. You’ll sleep better at night knowing deployments are consistent and reversible.
The barrier to entry is low—if you’re already using GitHub, you have everything you need. Start simple, test thoroughly on staging, and gradually build out more sophisticated workflows as you gain confidence.
Your future self—the one at 3 AM debugging a bad deployment—will thank you.