How to Automate WordPress Deployments with GitHub Actions

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:

  1. Edit theme files locally or in the WordPress editor
  2. Test on staging (if you have one)
  3. FTP or SSH into production
  4. Copy files over manually
  5. Hope nothing breaks
  6. Fix issues in production while users watch
  7. Remember too late that you forgot to update the database
  8. 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 domain
  • SSH_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:

  1. test job runs first — tests must pass before deployment happens
  2. MySQL service — spins up a test database automatically
  3. Multiple checks — PHP CodeSniffer, PHPUnit, custom lint scripts
  4. needs: test in deploy job — won’t run unless test succeeds
  5. Only deploys from main branch — protect against accidental deployments
  6. Database migrations — handled via WP-CLI
  7. 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:

ItemPurpose
SSH key without passphraseCI/CD can’t handle interactive prompts
Dedicated deploy user (not root)Principle of least privilege
MySQL user with limited permissionsShouldn’t have DROP/CREATE TABLE rights everywhere
Regular backups before deploymentInsurance policy
Monitoring/alerting on deploymentsKnow when things break immediately
Database migration strategyCan’t just push schema changes live
Health checks after deploymentVerify site is actually working
Slack/email notificationsTeam knows when deploys happen
Rollback procedure testedDon’t discover it doesn’t work during crisis
Rate limiting on staging deploysDon’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.

Affiliate Disclosure: This article may contain affiliate links. If you purchase through these links, TechChimney may earn a commission at no extra cost to you. We only recommend products we believe provide genuine value.