Back to Blog
DevOps & Deployment

WP-CLI for CI/CD: Custom Commands, Package Development, and Deployment Automation

Tom Bradley
50 min read

Why WP-CLI Belongs in Your Deployment Pipeline

WordPress powers a significant portion of the web, yet many teams still deploy WordPress sites by uploading files over FTP and clicking through the admin dashboard. This approach breaks down quickly when you manage five sites, let alone fifty. WP-CLI changes the equation entirely. It gives you a command-line interface to every meaningful WordPress operation: installing plugins, managing users, running database queries, flushing caches, and hundreds of other tasks that would otherwise require a browser.

But WP-CLI becomes genuinely powerful when you extend it. Custom commands let you encode your team’s specific deployment logic, data migration procedures, and maintenance routines into repeatable, testable CLI operations. Combined with CI/CD platforms like GitHub Actions, GitLab CI, or CircleCI, WP-CLI transforms WordPress deployment from a manual chore into an automated pipeline that runs on every push.

This article walks through the full lifecycle of WP-CLI package development: scaffolding a package, registering commands, writing tests, distributing your work, and wiring everything into a CI/CD pipeline. The examples here come from real production workflows. If you have shipped a WordPress site, you will recognize the problems these patterns solve.

Scaffolding a WP-CLI Package

WP-CLI packages follow a standard structure. You can set one up manually, but the wp scaffold package command generates the boilerplate for you, including the correct directory layout, a composer.json with the right autoload configuration, a PHPUnit bootstrap, and Behat feature files.

Generating the Package Skeleton

Run the scaffold command with your desired package name in vendor/package format:

wp scaffold package acme/deploy-tools \
  --dir=~/wp-cli-packages/deploy-tools \
  --description="Deployment and maintenance commands for Acme Corp WordPress sites" \
  --homepage="https://github.com/acme/deploy-tools" \
  --skip-tests

Drop the --skip-tests flag if you want Behat test scaffolding included from the start (and you should, but we will cover that separately). The generated structure looks like this:

deploy-tools/
├── command.php
├── composer.json
├── features/
│   ├── bootstrap/
│   │   └── FeatureContext.php
│   └── deploy-tools.feature
├── src/
│   └── DeployCommand.php
└── wp-cli.yml

The composer.json is the most important file. WP-CLI uses the extra section to discover your commands:

{
  "name": "acme/deploy-tools",
  "description": "Deployment and maintenance commands for Acme Corp WordPress sites",
  "type": "wp-cli-package",
  "license": "MIT",
  "autoload": {
    "psr-4": {
      "Acme\\DeployTools\\": "src/"
    },
    "files": [
      "command.php"
    ]
  },
  "require": {
    "wp-cli/wp-cli": "^2.7"
  },
  "extra": {
    "commands": [
      "deploy",
      "deploy sync",
      "deploy provision"
    ]
  }
}

The autoload.files entry points to command.php, which is where you register your commands. The extra.commands array tells WP-CLI which command names this package provides, used for lazy-loading so commands are only instantiated when actually called.

Local Development Setup

During development, link your package locally instead of installing it from Packagist:

# From the package directory
composer install

# Link it into WP-CLI's package path
wp package install /path/to/deploy-tools/

Alternatively, add a path repository to your WP-CLI package composer.json:

wp package path
# Returns something like: /home/you/.wp-cli/packages/

Edit that directory’s composer.json to add a path repository pointing to your development directory. This gives you live reloading during development since Composer creates a symlink rather than copying files.

Command Registration: Closures vs. Class-Based Commands

WP-CLI offers two patterns for registering commands. Each has its place, and understanding when to use which pattern saves you refactoring time later.

Closure-Based Commands

For quick, single-purpose commands, register a closure directly in command.php:

<?php

if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
    return;
}

WP_CLI::add_command( 'deploy ping', function( $args, $assoc_args ) {
    $site_url = get_site_url();
    $response = wp_remote_get( $site_url );

    if ( is_wp_error( $response ) ) {
        WP_CLI::error( sprintf( 'Site unreachable: %s', $response->get_error_message() ) );
    }

    $code = wp_remote_retrieve_response_code( $response );
    if ( $code === 200 ) {
        WP_CLI::success( sprintf( 'Site responding at %s (HTTP %d)', $site_url, $code ) );
    } else {
        WP_CLI::warning( sprintf( 'Site returned HTTP %d', $code ) );
    }
});

This works fine for simple operations. The command shows up as wp deploy ping and does exactly one thing. But closures have limitations: you cannot define synopsis metadata (argument descriptions, default values, optional flags) in a structured way, and you cannot share code between related subcommands without awkward function extraction.

Class-Based Commands

For anything beyond a trivial command, use a class that extends or follows the WP-CLI command pattern. You do not need to extend a base class. WP-CLI discovers public methods and registers them as subcommands:

<?php

namespace Acme\DeployTools;

use WP_CLI;
use WP_CLI\Utils;

class DeployCommand {

    /**
     * Run a full deployment sequence.
     *
     * ## OPTIONS
     *
     * [--skip-maintenance]
     * : Skip enabling maintenance mode during deployment.
     *
     * [--skip-cache-flush]
     * : Do not flush object cache after deployment.
     *
     * [--dry-run]
     * : Show what would happen without making changes.
     *
     * ## EXAMPLES
     *
     *     wp deploy run
     *     wp deploy run --skip-maintenance --dry-run
     *
     * @when after_wp_load
     */
    public function run( $args, $assoc_args ) {
        $dry_run    = Utils\get_flag_value( $assoc_args, 'dry-run', false );
        $skip_maint = Utils\get_flag_value( $assoc_args, 'skip-maintenance', false );
        $skip_cache = Utils\get_flag_value( $assoc_args, 'skip-cache-flush', false );

        if ( $dry_run ) {
            WP_CLI::log( 'Dry run mode enabled. No changes will be made.' );
        }

        // Step 1: Maintenance mode
        if ( ! $skip_maint ) {
            $this->enable_maintenance( $dry_run );
        }

        // Step 2: Run pending database migrations
        $this->run_migrations( $dry_run );

        // Step 3: Flush rewrite rules
        if ( ! $dry_run ) {
            flush_rewrite_rules();
            WP_CLI::log( 'Rewrite rules flushed.' );
        }

        // Step 4: Flush object cache
        if ( ! $skip_cache && ! $dry_run ) {
            wp_cache_flush();
            WP_CLI::log( 'Object cache flushed.' );
        }

        // Step 5: Disable maintenance mode
        if ( ! $skip_maint ) {
            $this->disable_maintenance( $dry_run );
        }

        WP_CLI::success( 'Deployment complete.' );
    }

    /**
     * Check deployment prerequisites.
     *
     * Verifies that the environment meets all requirements
     * before running a deployment.
     *
     * ## EXAMPLES
     *
     *     wp deploy preflight
     *
     * @when after_wp_load
     */
    public function preflight( $args, $assoc_args ) {
        $checks = [
            'PHP version'      => version_compare( PHP_VERSION, '8.0', '>=' ),
            'WordPress version' => version_compare( get_bloginfo( 'version' ), '6.0', '>=' ),
            'WP_DEBUG disabled' => ! WP_DEBUG,
            'Object cache'     => wp_using_ext_object_cache(),
        ];

        $table_data = [];
        $all_passed = true;

        foreach ( $checks as $label => $passed ) {
            $table_data[] = [
                'Check'  => $label,
                'Status' => $passed ? 'PASS' : 'FAIL',
            ];
            if ( ! $passed ) {
                $all_passed = false;
            }
        }

        Utils\format_items( 'table', $table_data, [ 'Check', 'Status' ] );

        if ( $all_passed ) {
            WP_CLI::success( 'All preflight checks passed.' );
        } else {
            WP_CLI::warning( 'Some preflight checks failed. Review before deploying.' );
        }
    }

    private function enable_maintenance( $dry_run ) {
        if ( $dry_run ) {
            WP_CLI::log( '[DRY RUN] Would enable maintenance mode.' );
            return;
        }
        $file = ABSPATH . '.maintenance';
        file_put_contents( $file, '<?php $upgrading = ' . time() . '; ?>' );
        WP_CLI::log( 'Maintenance mode enabled.' );
    }

    private function disable_maintenance( $dry_run ) {
        if ( $dry_run ) {
            WP_CLI::log( '[DRY RUN] Would disable maintenance mode.' );
            return;
        }
        $file = ABSPATH . '.maintenance';
        if ( file_exists( $file ) ) {
            unlink( $file );
        }
        WP_CLI::log( 'Maintenance mode disabled.' );
    }

    private function run_migrations( $dry_run ) {
        // Migration logic covered in detail later in this article
        WP_CLI::log( $dry_run ? '[DRY RUN] Would run migrations.' : 'Migrations executed.' );
    }
}

Then register the class in command.php:

<?php

if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
    return;
}

WP_CLI::add_command( 'deploy', 'Acme\\DeployTools\\DeployCommand' );

Each public method becomes a subcommand. The PHPDoc annotations define the synopsis: ## OPTIONS declares flags and arguments, ## EXAMPLES provides usage examples, and @when controls the loading hook. Run wp help deploy run and WP-CLI parses that PHPDoc into formatted help text automatically.

Subcommand Organization

For larger packages, split commands across multiple classes and register them at different levels:

WP_CLI::add_command( 'deploy',      'Acme\\DeployTools\\DeployCommand' );
WP_CLI::add_command( 'deploy db',   'Acme\\DeployTools\\DatabaseCommand' );
WP_CLI::add_command( 'deploy seed', 'Acme\\DeployTools\\SeedCommand' );
WP_CLI::add_command( 'deploy env',  'Acme\\DeployTools\\EnvironmentCommand' );

This produces a clean command tree:

wp deploy run          # Main deployment
wp deploy preflight    # Pre-deployment checks
wp deploy db migrate   # Database migrations
wp deploy db rollback  # Rollback last migration
wp deploy seed users   # Seed user data
wp deploy seed content # Seed content
wp deploy env check    # Environment verification

WP_CLI\Utils Helpers: Building a Professional CLI Experience

Raw echo statements produce ugly, hard-to-parse output. WP-CLI provides a rich set of utilities for building commands that behave like polished CLI tools. These helpers live in the WP_CLI\Utils namespace and cover formatting, user interaction, and progress reporting.

Progress Bars

When processing large datasets, a progress bar tells the operator something is actually happening:

/**
 * Regenerate thumbnails for all attachments.
 *
 * ## OPTIONS
 *
 * [--batch-size=<number>]
 * : Number of attachments to process per batch.
 * ---
 * default: 50
 * ---
 *
 * @when after_wp_load
 */
public function regenerate_thumbs( $args, $assoc_args ) {
    $batch_size = (int) $assoc_args['batch-size'];

    $attachment_ids = get_posts( [
        'post_type'      => 'attachment',
        'post_mime_type' => 'image',
        'posts_per_page' => -1,
        'fields'         => 'ids',
    ] );

    $total = count( $attachment_ids );

    if ( $total === 0 ) {
        WP_CLI::warning( 'No image attachments found.' );
        return;
    }

    $progress = Utils\make_progress_bar( 'Regenerating thumbnails', $total );

    $success_count = 0;
    $error_count   = 0;

    foreach ( $attachment_ids as $id ) {
        $metadata = wp_generate_attachment_metadata(
            $id,
            get_attached_file( $id )
        );

        if ( is_wp_error( $metadata ) || empty( $metadata ) ) {
            WP_CLI::debug( sprintf( 'Failed to regenerate attachment %d', $id ) );
            $error_count++;
        } else {
            wp_update_attachment_metadata( $id, $metadata );
            $success_count++;
        }

        $progress->tick();
    }

    $progress->finish();

    WP_CLI::success(
        sprintf( 'Processed %d attachments: %d succeeded, %d failed.',
            $total, $success_count, $error_count
        )
    );
}

The progress bar renders as a familiar ASCII bar that updates in place:

Regenerating thumbnails  42% [=====================                              ] 210/500 ETA: 00:01:23

Table Output

Structured data looks best in a table. The format_items helper supports multiple output formats (table, csv, json, yaml) controlled by a --format flag:

/**
 * List all registered cron events.
 *
 * ## OPTIONS
 *
 * [--format=<format>]
 * : Output format.
 * ---
 * default: table
 * options:
 *   - table
 *   - csv
 *   - json
 *   - yaml
 * ---
 *
 * @when after_wp_load
 */
public function cron_list( $args, $assoc_args ) {
    $crons = _get_cron_array();
    $items = [];

    foreach ( $crons as $timestamp => $hooks ) {
        foreach ( $hooks as $hook => $events ) {
            foreach ( $events as $event ) {
                $items[] = [
                    'Hook'       => $hook,
                    'Next Run'   => date( 'Y-m-d H:i:s', $timestamp ),
                    'Recurrence' => $event['schedule'] ?: 'one-time',
                ];
            }
        }
    }

    $format = Utils\get_flag_value( $assoc_args, 'format', 'table' );
    Utils\format_items( $format, $items, [ 'Hook', 'Next Run', 'Recurrence' ] );
}

When an operator passes --format=json, the output becomes machine-readable, which matters enormously in CI/CD pipelines where one command’s output feeds into the next.

Colorized Output and Log Levels

WP-CLI provides several output methods, each with a specific purpose:

// Green checkmark - operation succeeded
WP_CLI::success( 'Database migrated to version 14.' );

// Red X and exits with code 1 - fatal error
WP_CLI::error( 'Migration file not found: 014-add-index.sql' );

// Yellow warning - non-fatal issue
WP_CLI::warning( 'Cache plugin not detected. Performance may be affected.' );

// Standard output - always visible
WP_CLI::log( 'Processing batch 3 of 12...' );

// Only visible with --debug flag
WP_CLI::debug( sprintf( 'Query executed in %.4f seconds', $elapsed ), 'deploy' );

// Colorized inline text
WP_CLI::log( WP_CLI::colorize( '%GDeployed%n to %Y' . $environment . '%n at ' . date( 'H:i:s' ) ) );

The color codes are: %R red, %G green, %B blue, %Y yellow, %M magenta, %C cyan, %W white, %K black, %n reset. Use %1 through %7 for background colors.

Confirmation Prompts

Destructive operations should ask for confirmation, but CI pipelines need a way to skip the prompt:

/**
 * Purge all transients from the database.
 *
 * ## OPTIONS
 *
 * [--yes]
 * : Skip confirmation prompt.
 *
 * [--expired-only]
 * : Only delete expired transients.
 *
 * @when after_wp_load
 */
public function purge_transients( $args, $assoc_args ) {
    global $wpdb;

    $expired_only = Utils\get_flag_value( $assoc_args, 'expired-only', false );

    if ( $expired_only ) {
        $count = $wpdb->query(
            "DELETE a, b FROM {$wpdb->options} a
            INNER JOIN {$wpdb->options} b ON b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
            WHERE a.option_name LIKE '_transient_%'
            AND b.option_value < UNIX_TIMESTAMP()"
        );
    } else {
        $count = $wpdb->query(
            "SELECT COUNT(*) FROM {$wpdb->options}
            WHERE option_name LIKE '_transient_%'"
        );
        WP_CLI::confirm(
            sprintf( 'This will delete %d transient entries. Continue?', $count ),
            $assoc_args
        );
        $count = $wpdb->query(
            "DELETE FROM {$wpdb->options}
            WHERE option_name LIKE '_transient_%'"
        );
    }

    WP_CLI::success( sprintf( 'Removed %d transient entries.', $count ) );
}

The WP_CLI::confirm() method checks for --yes in $assoc_args. If present, it skips the interactive prompt. In your CI/CD pipeline, always pass --yes to avoid hanging on user input.

Writing Behat Tests for Custom Commands

WP-CLI uses Behat (a BDD testing framework) for functional testing rather than PHPUnit. This makes sense because you are testing command-line behavior, not unit-level PHP logic. Behat tests describe scenarios in plain English (Gherkin syntax) and verify the actual CLI output.

Setting Up the Test Environment

If you used wp scaffold package without --skip-tests, you already have the Behat configuration. Otherwise, set it up manually:

# Install test dependencies
composer require --dev wp-cli/wp-cli-tests
composer require --dev behat/behat

# Create the features directory
mkdir -p features/bootstrap

Your behat.yml should reference the WP-CLI test context:

default:
  suites:
    default:
      contexts:
        - WP_CLI\Tests\Context\FeatureContext
      paths:
        - features
  extensions:
    WP_CLI\Tests\Context\:
      config: features/bootstrap/config.php

Writing Feature Files

Each feature file describes a set of scenarios for a command. Here is a test for the deployment preflight check:

Feature: Deployment preflight checks

  Scenario: Preflight passes on a healthy installation
    Given a WP installation
    And I run `wp plugin activate query-monitor`

    When I run `wp deploy preflight`
    Then STDOUT should contain:
      """
      PASS
      """
    And the return code should be 0

  Scenario: Preflight warns about WP_DEBUG
    Given a WP installation
    And a wp-config.php file:
      """
      define( 'WP_DEBUG', true );
      """

    When I run `wp deploy preflight`
    Then STDOUT should contain:
      """
      FAIL
      """
    And STDERR should contain:
      """
      Warning
      """

  Scenario: Database migration runs successfully
    Given a WP installation
    And a file named 'migrations/001-create-logs-table.sql' with:
      """
      CREATE TABLE IF NOT EXISTS wp_deploy_logs (
        id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        message TEXT NOT NULL,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP
      );
      """

    When I run `wp deploy db migrate --migration-path=migrations/`
    Then STDOUT should contain:
      """
      Migration 001-create-logs-table.sql applied
      """
    And the return code should be 0

  Scenario: Dry run does not modify the database
    Given a WP installation

    When I run `wp deploy run --dry-run`
    Then STDOUT should contain:
      """
      Dry run mode enabled
      """
    And STDOUT should contain:
      """
      [DRY RUN]
      """
    And the return code should be 0

Running the Tests

# Set the path to a WordPress test installation
export WP_CLI_BIN_DIR=/tmp/wp-cli-phar
export WP_CLI_CONFIG_PATH=/tmp/wp-cli-test-config.yml

# Run all features
vendor/bin/behat

# Run a specific feature
vendor/bin/behat features/deploy-preflight.feature

# Run a specific scenario by line number
vendor/bin/behat features/deploy-preflight.feature:3

Each scenario spins up a fresh WordPress installation, runs your commands against it, and tears it down. This isolation means tests do not interfere with each other, and you can run the full suite in CI without worrying about state leakage.

Custom Step Definitions

Sometimes the built-in Behat steps are not enough. Add custom step definitions in features/bootstrap/FeatureContext.php:

<?php

use Behat\Behat\Context\Context;
use WP_CLI\Tests\Context\FeatureContext as WPCLIFeatureContext;

class FeatureContext extends WPCLIFeatureContext implements Context {

    /**
     * @Then the database should have a table :table_name
     */
    public function theDatabaseShouldHaveATable( $table_name ) {
        $this->proc( "wp db query \"SHOW TABLES LIKE '{$table_name}'\"" )->run_check();
        $output = $this->result->stdout;

        if ( strpos( $output, $table_name ) === false ) {
            throw new \Exception(
                sprintf( 'Expected table "%s" to exist, but it was not found.', $table_name )
            );
        }
    }

    /**
     * @Given a migration file :filename with content:
     */
    public function aMigrationFileWithContent( $filename, $content ) {
        $dir = $this->variables['RUN_DIR'] . '/migrations';
        if ( ! is_dir( $dir ) ) {
            mkdir( $dir, 0755, true );
        }
        file_put_contents( $dir . '/' . $filename, $content );
    }
}

Distributing Packages via Packagist and wp package install

Once your package is tested and stable, you have several distribution options.

Publishing to Packagist

The simplest route is publishing to Packagist, since WP-CLI uses Composer under the hood:

  1. Push your package to a public GitHub repository.
  2. Ensure composer.json has the correct name, type: "wp-cli-package", and a version tag.
  3. Register the package on packagist.org.
  4. Tag a release: git tag v1.0.0 && git push --tags

Users can then install your package directly:

wp package install acme/deploy-tools

Installing from Git Repositories

For private packages or packages not on Packagist, install directly from a Git URL:

# Public GitHub repo
wp package install [email protected]:acme/deploy-tools.git

# Specific branch
wp package install [email protected]:acme/deploy-tools.git:dev-main

# Specific tag
wp package install [email protected]:acme/deploy-tools.git:v1.2.0

Private Distribution with Satis

For organizations that need private package distribution, set up a Satis repository. Satis is a static Composer repository generator:

{
  "name": "acme/wp-cli-packages",
  "homepage": "https://packages.acme.internal",
  "repositories": [
    {
      "type": "vcs",
      "url": "[email protected]:acme/deploy-tools.git"
    },
    {
      "type": "vcs",
      "url": "[email protected]:acme/content-tools.git"
    }
  ],
  "require-all": true
}

Then configure WP-CLI to use your private repository by adding to ~/.wp-cli/config.yml:

package index:
  url: https://packages.acme.internal/packages.json

CI/CD Integration: GitHub Actions Workflow for WP-CLI Deployments

This is where all the previous work pays off. A well-structured WP-CLI package combined with a CI/CD pipeline automates the entire deployment process: testing, building assets, pushing code, running migrations, and verifying the deployment.

Complete GitHub Actions Workflow

Here is a production-grade workflow file that handles deployment to staging and production environments:

name: Deploy WordPress

on:
  push:
    branches:
      - main
      - staging
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options:
          - staging
          - production
      skip_migrations:
        description: 'Skip database migrations'
        required: false
        type: boolean
        default: false

env:
  WP_CLI_VERSION: '2.8.1'
  PHP_VERSION: '8.2'
  NODE_VERSION: '18'

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: wordpress_test
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

    steps:
      - uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          extensions: mysqli, zip, gd, intl
          tools: composer:v2, wp-cli

      - name: Cache Composer dependencies
        uses: actions/cache@v3
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - name: Install dependencies
        run: composer install --no-dev --prefer-dist

      - name: Install WP-CLI packages
        run: |
          wp package install acme/deploy-tools
          wp cli info

      - name: Run Behat tests
        run: |
          cd wp-content/plugins/deploy-tools
          vendor/bin/behat --format=progress
        env:
          WP_CLI_BIN_DIR: /usr/local/bin

  build:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
          cache-dependency-path: wp-content/themes/*/package-lock.json

      - name: Build theme assets
        run: |
          cd wp-content/themes/wpkite
          npm ci
          npm run build

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: build-${{ github.sha }}
          path: |
            wp-content/themes/
            wp-content/plugins/
            vendor/
          retention-days: 5

  deploy:
    runs-on: ubuntu-latest
    needs: build
    environment:
      name: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
      url: ${{ github.ref == 'refs/heads/main' && 'https://example.com' || 'https://staging.example.com' }}

    steps:
      - uses: actions/checkout@v4

      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: build-${{ github.sha }}

      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts

      - name: Determine environment
        id: env
        run: |
          if [ "${{ github.ref }}" = "refs/heads/main" ]; then
            echo "host=${{ secrets.PROD_HOST }}" >> $GITHUB_OUTPUT
            echo "path=${{ secrets.PROD_PATH }}" >> $GITHUB_OUTPUT
            echo "name=production" >> $GITHUB_OUTPUT
          else
            echo "host=${{ secrets.STAGING_HOST }}" >> $GITHUB_OUTPUT
            echo "path=${{ secrets.STAGING_PATH }}" >> $GITHUB_OUTPUT
            echo "name=staging" >> $GITHUB_OUTPUT
          fi

      - name: Deploy files via rsync
        run: |
          rsync -avz --delete \
            --exclude='.git' \
            --exclude='node_modules' \
            --exclude='.env' \
            --exclude='wp-config.php' \
            --exclude='wp-content/uploads' \
            --exclude='wp-content/cache' \
            -e "ssh -i ~/.ssh/deploy_key" \
            ./ ${{ secrets.DEPLOY_USER }}@${{ steps.env.outputs.host }}:${{ steps.env.outputs.path }}/

      - name: Run post-deployment WP-CLI commands
        run: |
          ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ steps.env.outputs.host }} << 'REMOTE'
            cd ${{ steps.env.outputs.path }}

            # Run preflight check
            wp deploy preflight

            # Run deployment sequence
            wp deploy run --yes --skip-maintenance

            # Run database migrations if not skipped
            if [ "${{ inputs.skip_migrations }}" != "true" ]; then
              wp deploy db migrate --yes
            fi

            # Verify the deployment
            wp deploy ping

            # Clear all caches
            wp cache flush
            wp rewrite flush
            wp transient delete --all

            # Log the deployment
            wp option update last_deploy_sha "${{ github.sha }}"
            wp option update last_deploy_time "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
            wp option update last_deploy_env "${{ steps.env.outputs.name }}"
          REMOTE

      - name: Verify deployment
        run: |
          SITE_URL="${{ github.ref == 'refs/heads/main' && 'https://example.com' || 'https://staging.example.com' }}"
          HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$SITE_URL")
          if [ "$HTTP_CODE" != "200" ]; then
            echo "::error::Site returned HTTP $HTTP_CODE after deployment"
            exit 1
          fi
          echo "Site responding with HTTP 200"

      - name: Notify on failure
        if: failure()
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H 'Content-Type: application/json' \
            -d "{\"text\": \"Deployment to ${{ steps.env.outputs.name }} failed for commit ${{ github.sha }}\"}"

SSH-Based WP-CLI Execution

The workflow above SSHs into the server to run WP-CLI commands. An alternative approach uses WP-CLI's built-in SSH support via aliases in wp-cli.yml:

@staging:
  ssh: [email protected]/var/www/staging
@production:
  ssh: [email protected]/var/www/production

With aliases configured, you can run commands against remote environments from your local machine or CI runner:

wp @staging deploy preflight
wp @production cache flush
wp @staging deploy db migrate --yes

This is cleaner for simple operations, though for full deployment sequences the SSH heredoc approach gives you more control over error handling and conditional logic.

Database Migration Scripts and Content Seeding via WP-CLI

Schema changes and data transformations are among the trickiest parts of WordPress deployment. WordPress does not have a built-in migration system like Rails or Laravel. WP-CLI lets you build one.

A File-Based Migration System

The concept is straightforward: migration files are numbered SQL or PHP scripts stored in a directory. A database record tracks which migrations have been applied. Each deployment runs only the new migrations.

<?php

namespace Acme\DeployTools;

use WP_CLI;
use WP_CLI\Utils;

class DatabaseCommand {

    private $migrations_table = 'wp_deploy_migrations';

    /**
     * Run pending database migrations.
     *
     * ## OPTIONS
     *
     * [--migration-path=<path>]
     * : Directory containing migration files.
     * ---
     * default: migrations/
     * ---
     *
     * [--dry-run]
     * : Show which migrations would run without executing them.
     *
     * [--yes]
     * : Skip confirmation prompt.
     *
     * @when after_wp_load
     */
    public function migrate( $args, $assoc_args ) {
        global $wpdb;

        $path    = rtrim( $assoc_args['migration-path'], '/' );
        $dry_run = Utils\get_flag_value( $assoc_args, 'dry-run', false );

        $this->ensure_migrations_table();

        // Get list of already-applied migrations
        $applied = $wpdb->get_col(
            "SELECT migration_name FROM {$this->migrations_table} ORDER BY id"
        );

        // Get all migration files
        $files = glob( "{$path}/*.{sql,php}", GLOB_BRACE );
        if ( empty( $files ) ) {
            WP_CLI::warning( "No migration files found in {$path}/" );
            return;
        }

        sort( $files );

        $pending = array_filter( $files, function( $file ) use ( $applied ) {
            return ! in_array( basename( $file ), $applied, true );
        });

        if ( empty( $pending ) ) {
            WP_CLI::success( 'No pending migrations.' );
            return;
        }

        WP_CLI::log( sprintf( 'Found %d pending migration(s).', count( $pending ) ) );

        if ( $dry_run ) {
            foreach ( $pending as $file ) {
                WP_CLI::log( sprintf( '  [PENDING] %s', basename( $file ) ) );
            }
            return;
        }

        WP_CLI::confirm(
            sprintf( 'Apply %d migration(s)?', count( $pending ) ),
            $assoc_args
        );

        $progress = Utils\make_progress_bar( 'Running migrations', count( $pending ) );

        foreach ( $pending as $file ) {
            $filename  = basename( $file );
            $extension = pathinfo( $file, PATHINFO_EXTENSION );

            try {
                if ( $extension === 'sql' ) {
                    $sql = file_get_contents( $file );
                    $wpdb->query( $sql );
                    if ( $wpdb->last_error ) {
                        throw new \RuntimeException( $wpdb->last_error );
                    }
                } elseif ( $extension === 'php' ) {
                    require $file;
                }

                // Record the migration
                $wpdb->insert( $this->migrations_table, [
                    'migration_name' => $filename,
                    'applied_at'     => current_time( 'mysql' ),
                ] );

                WP_CLI::debug( "Applied migration: {$filename}" );

            } catch ( \Exception $e ) {
                WP_CLI::error(
                    sprintf( 'Migration %s failed: %s', $filename, $e->getMessage() )
                );
            }

            $progress->tick();
        }

        $progress->finish();
        WP_CLI::success( sprintf( 'Applied %d migration(s).', count( $pending ) ) );
    }

    /**
     * Rollback the last applied migration.
     *
     * ## OPTIONS
     *
     * [--steps=<number>]
     * : Number of migrations to roll back.
     * ---
     * default: 1
     * ---
     *
     * [--yes]
     * : Skip confirmation prompt.
     *
     * @when after_wp_load
     */
    public function rollback( $args, $assoc_args ) {
        global $wpdb;

        $steps = (int) $assoc_args['steps'];

        $this->ensure_migrations_table();

        $last_migrations = $wpdb->get_results( $wpdb->prepare(
            "SELECT * FROM {$this->migrations_table} ORDER BY id DESC LIMIT %d",
            $steps
        ) );

        if ( empty( $last_migrations ) ) {
            WP_CLI::warning( 'No migrations to roll back.' );
            return;
        }

        $names = wp_list_pluck( $last_migrations, 'migration_name' );
        WP_CLI::log( 'Migrations to roll back:' );
        foreach ( $names as $name ) {
            WP_CLI::log( "  - {$name}" );
        }

        WP_CLI::confirm( 'Proceed with rollback?', $assoc_args );

        foreach ( $last_migrations as $migration ) {
            $rollback_file = str_replace( '.sql', '.rollback.sql', $migration->migration_name );
            $rollback_file = str_replace( '.php', '.rollback.php', $rollback_file );

            $path = "migrations/{$rollback_file}";
            if ( file_exists( $path ) ) {
                if ( pathinfo( $path, PATHINFO_EXTENSION ) === 'sql' ) {
                    $wpdb->query( file_get_contents( $path ) );
                } else {
                    require $path;
                }
            } else {
                WP_CLI::warning( "No rollback file found for {$migration->migration_name}" );
            }

            $wpdb->delete( $this->migrations_table, [ 'id' => $migration->id ] );
            WP_CLI::log( "Rolled back: {$migration->migration_name}" );
        }

        WP_CLI::success( sprintf( 'Rolled back %d migration(s).', count( $last_migrations ) ) );
    }

    /**
     * Show migration status.
     *
     * @when after_wp_load
     */
    public function status( $args, $assoc_args ) {
        global $wpdb;

        $this->ensure_migrations_table();

        $applied = $wpdb->get_results(
            "SELECT migration_name, applied_at FROM {$this->migrations_table} ORDER BY id"
        );

        if ( empty( $applied ) ) {
            WP_CLI::log( 'No migrations have been applied.' );
            return;
        }

        $items = array_map( function( $row ) {
            return [
                'Migration'  => $row->migration_name,
                'Applied At' => $row->applied_at,
            ];
        }, $applied );

        Utils\format_items( 'table', $items, [ 'Migration', 'Applied At' ] );
    }

    private function ensure_migrations_table() {
        global $wpdb;

        $charset = $wpdb->get_charset_collate();
        $wpdb->query(
            "CREATE TABLE IF NOT EXISTS {$this->migrations_table} (
                id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                migration_name VARCHAR(255) NOT NULL UNIQUE,
                applied_at DATETIME NOT NULL
            ) {$charset}"
        );
    }
}

Content Seeding

Seeding is useful for setting up demo environments, running QA, or provisioning new client sites with starter content:

<?php

namespace Acme\DeployTools;

use WP_CLI;
use WP_CLI\Utils;

class SeedCommand {

    /**
     * Seed the database with sample content.
     *
     * ## OPTIONS
     *
     * <type>
     * : Type of content to seed.
     * ---
     * options:
     *   - users
     *   - posts
     *   - pages
     *   - all
     * ---
     *
     * [--count=<number>]
     * : Number of items to create.
     * ---
     * default: 10
     * ---
     *
     * [--yes]
     * : Skip confirmation.
     *
     * @when after_wp_load
     */
    public function __invoke( $args, $assoc_args ) {
        $type  = $args[0];
        $count = (int) $assoc_args['count'];

        WP_CLI::confirm(
            sprintf( 'Seed %d %s item(s)?', $count, $type ),
            $assoc_args
        );

        switch ( $type ) {
            case 'users':
                $this->seed_users( $count );
                break;
            case 'posts':
                $this->seed_posts( $count );
                break;
            case 'pages':
                $this->seed_pages( $count );
                break;
            case 'all':
                $this->seed_users( $count );
                $this->seed_posts( $count );
                $this->seed_pages( $count );
                break;
        }
    }

    private function seed_users( $count ) {
        $progress = Utils\make_progress_bar( 'Seeding users', $count );
        $roles    = [ 'subscriber', 'author', 'editor' ];

        for ( $i = 1; $i <= $count; $i++ ) {
            $username = sprintf( 'testuser_%d_%s', $i, wp_generate_password( 4, false ) );
            wp_insert_user( [
                'user_login' => $username,
                'user_email' => "{$username}@example.com",
                'user_pass'  => wp_generate_password( 16 ),
                'role'       => $roles[ array_rand( $roles ) ],
                'first_name' => "Test",
                'last_name'  => "User {$i}",
            ] );
            $progress->tick();
        }

        $progress->finish();
        WP_CLI::success( "Seeded {$count} users." );
    }

    private function seed_posts( $count ) {
        $progress = Utils\make_progress_bar( 'Seeding posts', $count );
        $statuses = [ 'publish', 'draft', 'publish', 'publish' ];

        for ( $i = 1; $i <= $count; $i++ ) {
            wp_insert_post( [
                'post_title'   => sprintf( 'Sample Post %d: %s', $i, wp_generate_password( 8, false ) ),
                'post_content' => $this->generate_paragraphs( rand( 3, 8 ) ),
                'post_status'  => $statuses[ array_rand( $statuses ) ],
                'post_author'  => 1,
                'post_type'    => 'post',
            ] );
            $progress->tick();
        }

        $progress->finish();
        WP_CLI::success( "Seeded {$count} posts." );
    }

    private function seed_pages( $count ) {
        $progress   = Utils\make_progress_bar( 'Seeding pages', $count );
        $page_names = [ 'About', 'Services', 'Contact', 'FAQ', 'Careers',
                        'Privacy Policy', 'Terms of Service', 'Blog', 'Portfolio', 'Testimonials' ];

        for ( $i = 0; $i < $count; $i++ ) {
            $title = $page_names[ $i % count( $page_names ) ];
            if ( $i >= count( $page_names ) ) {
                $title .= ' ' . ceil( $i / count( $page_names ) );
            }
            wp_insert_post( [
                'post_title'   => $title,
                'post_content' => $this->generate_paragraphs( rand( 2, 5 ) ),
                'post_status'  => 'publish',
                'post_author'  => 1,
                'post_type'    => 'page',
            ] );
            $progress->tick();
        }

        $progress->finish();
        WP_CLI::success( "Seeded {$count} pages." );
    }

    private function generate_paragraphs( $count ) {
        $paragraphs = [];
        $sentences  = [
            'WordPress powers a significant share of the web.',
            'Automated deployment reduces human error and speeds up releases.',
            'Database migrations should be idempotent and reversible.',
            'CI/CD pipelines give you confidence that every change is tested.',
            'WP-CLI turns manual admin tasks into scriptable operations.',
            'Content seeding helps QA teams test with realistic data.',
            'Version control and automated deploys go hand in hand.',
        ];

        for ( $i = 0; $i < $count; $i++ ) {
            shuffle( $sentences );
            $paragraph_sentences = array_slice( $sentences, 0, rand( 3, 5 ) );
            $paragraphs[] = '<!-- wp:paragraph --><p>' . implode( ' ', $paragraph_sentences ) . '</p><!-- /wp:paragraph -->';
        }

        return implode( "\n\n", $paragraphs );
    }
}

Error Handling, Logging, and Exit Codes for Automation

When a human runs a command and it fails, they read the error message and decide what to do. When a CI pipeline runs a command and it fails, the exit code determines whether the pipeline continues or stops. Getting error handling right is not optional for CI/CD integration.

Exit Code Conventions

WP-CLI follows standard Unix conventions:

  • 0 = success
  • 1 = general error (what WP_CLI::error() uses)
  • Any non-zero code = failure

You can control exit codes explicitly:

// Exit with a specific code
WP_CLI::halt( 2 );  // Custom exit code

// WP_CLI::error() exits with code 1 by default
WP_CLI::error( 'Something went wrong.' );

// Prevent WP_CLI::error() from exiting (useful in batch operations)
try {
    // risky operation
} catch ( \Exception $e ) {
    WP_CLI::warning( $e->getMessage() );
    // Continue processing instead of halting
}

Structured Error Handling Pattern

For commands that perform multiple steps, track errors and report them at the end rather than bailing on the first failure:

/**
 * Verify site integrity after deployment.
 *
 * @when after_wp_load
 */
public function verify( $args, $assoc_args ) {
    $errors   = [];
    $warnings = [];

    // Check 1: Database connectivity
    global $wpdb;
    $result = $wpdb->get_var( "SELECT 1" );
    if ( $result !== '1' ) {
        $errors[] = 'Database query failed.';
    }

    // Check 2: Required plugins active
    $required_plugins = [ 'wp-super-cache/wp-cache.php', 'wordfence/wordfence.php' ];
    foreach ( $required_plugins as $plugin ) {
        if ( ! is_plugin_active( $plugin ) ) {
            $errors[] = sprintf( 'Required plugin not active: %s', $plugin );
        }
    }

    // Check 3: Writable directories
    $writable_dirs = [ WP_CONTENT_DIR . '/uploads', WP_CONTENT_DIR . '/cache' ];
    foreach ( $writable_dirs as $dir ) {
        if ( ! is_writable( $dir ) ) {
            $warnings[] = sprintf( 'Directory not writable: %s', $dir );
        }
    }

    // Check 4: SSL certificate
    $response = wp_remote_get( home_url( '/' ), [ 'sslverify' => true ] );
    if ( is_wp_error( $response ) ) {
        $warnings[] = 'SSL verification failed: ' . $response->get_error_message();
    }

    // Report results
    foreach ( $warnings as $warning ) {
        WP_CLI::warning( $warning );
    }

    if ( ! empty( $errors ) ) {
        foreach ( $errors as $error ) {
            WP_CLI::log( WP_CLI::colorize( '%R[ERROR]%n ' . $error ) );
        }
        WP_CLI::error(
            sprintf( 'Verification failed with %d error(s) and %d warning(s).',
                count( $errors ), count( $warnings )
            )
        );
    }

    WP_CLI::success(
        sprintf( 'Verification passed. %d warning(s).', count( $warnings ) )
    );
}

Logging to Files

CI logs are ephemeral. For persistent logging, write to a dedicated log file alongside the CLI output:

class DeployLogger {

    private $log_file;

    public function __construct( $log_dir = null ) {
        $log_dir = $log_dir ?: WP_CONTENT_DIR . '/deploy-logs';
        if ( ! is_dir( $log_dir ) ) {
            mkdir( $log_dir, 0755, true );
        }
        $this->log_file = $log_dir . '/' . date( 'Y-m-d_H-i-s' ) . '_deploy.log';
    }

    public function info( $message ) {
        $this->write( 'INFO', $message );
        WP_CLI::log( $message );
    }

    public function error( $message ) {
        $this->write( 'ERROR', $message );
        // Do not call WP_CLI::error() here since that exits.
        // Let the caller decide whether to halt.
    }

    public function success( $message ) {
        $this->write( 'SUCCESS', $message );
        WP_CLI::success( $message );
    }

    private function write( $level, $message ) {
        $line = sprintf(
            "[%s] [%s] %s\n",
            date( 'Y-m-d H:i:s' ),
            $level,
            $message
        );
        file_put_contents( $this->log_file, $line, FILE_APPEND );
    }

    public function get_log_path() {
        return $this->log_file;
    }
}

Use it in your commands:

public function run( $args, $assoc_args ) {
    $logger = new DeployLogger();

    $logger->info( 'Deployment started by ' . get_current_user() );
    $logger->info( 'Git SHA: ' . trim( shell_exec( 'git rev-parse HEAD' ) ) );

    // ... deployment steps ...

    $logger->success( 'Deployment completed.' );
    WP_CLI::log( sprintf( 'Full log: %s', $logger->get_log_path() ) );
}

Advanced Scripting Patterns: Batch Processing with Memory Limits

WordPress was designed to handle a single HTTP request, not to process 100,000 records in a loop. WP-CLI commands that iterate over large datasets will hit PHP memory limits unless you manage memory explicitly.

The Memory Problem

Consider a naive approach to processing all posts:

// This will crash on a site with 50,000+ posts
$posts = get_posts( [ 'posts_per_page' => -1 ] );
foreach ( $posts as $post ) {
    // process each post
}

Loading every post object into memory at once is a guaranteed out-of-memory error on large sites. The solution is batch processing: fetch a limited number of records, process them, free the memory, and fetch the next batch.

Batch Processing with Memory Management

/**
 * Update SEO metadata for all published posts.
 *
 * Processes posts in batches to avoid memory exhaustion.
 *
 * ## OPTIONS
 *
 * [--batch-size=<number>]
 * : Posts per batch.
 * ---
 * default: 100
 * ---
 *
 * [--post-type=<type>]
 * : Post type to process.
 * ---
 * default: post
 * ---
 *
 * [--dry-run]
 * : Preview changes without writing to the database.
 *
 * @when after_wp_load
 */
public function update_seo_meta( $args, $assoc_args ) {
    global $wpdb;

    $batch_size = (int) $assoc_args['batch-size'];
    $post_type  = $assoc_args['post-type'];
    $dry_run    = Utils\get_flag_value( $assoc_args, 'dry-run', false );

    // Get total count first (lightweight query)
    $total = (int) $wpdb->get_var( $wpdb->prepare(
        "SELECT COUNT(ID) FROM {$wpdb->posts}
        WHERE post_type = %s AND post_status = 'publish'",
        $post_type
    ) );

    if ( $total === 0 ) {
        WP_CLI::warning( "No published {$post_type} posts found." );
        return;
    }

    WP_CLI::log( sprintf( 'Processing %d posts in batches of %d.', $total, $batch_size ) );

    $progress      = Utils\make_progress_bar( 'Updating SEO metadata', $total );
    $offset        = 0;
    $updated_count = 0;
    $skipped_count = 0;

    while ( $offset < $total ) {
        // Fetch one batch of IDs only (not full post objects)
        $post_ids = $wpdb->get_col( $wpdb->prepare(
            "SELECT ID FROM {$wpdb->posts}
            WHERE post_type = %s AND post_status = 'publish'
            ORDER BY ID ASC
            LIMIT %d OFFSET %d",
            $post_type, $batch_size, $offset
        ) );

        foreach ( $post_ids as $post_id ) {
            $post = get_post( $post_id );

            // Generate SEO title from post title
            $seo_title = wp_trim_words( $post->post_title, 10, '' ) . ' - ' . get_bloginfo( 'name' );

            // Generate meta description from content
            $seo_desc = wp_trim_words(
                wp_strip_all_tags( $post->post_content ),
                25,
                '...'
            );

            if ( ! $dry_run ) {
                update_post_meta( $post_id, '_seo_title', $seo_title );
                update_post_meta( $post_id, '_seo_description', $seo_desc );
                $updated_count++;
            } else {
                WP_CLI::debug( sprintf(
                    'Would update post %d: title="%s" desc="%s"',
                    $post_id, $seo_title, substr( $seo_desc, 0, 50 )
                ) );
                $skipped_count++;
            }

            $progress->tick();
        }

        $offset += $batch_size;

        // Critical: free memory between batches
        $this->stop_the_insanity();
    }

    $progress->finish();

    if ( $dry_run ) {
        WP_CLI::success( sprintf( 'Dry run complete. Would update %d posts.', $skipped_count ) );
    } else {
        WP_CLI::success( sprintf( 'Updated SEO metadata for %d posts.', $updated_count ) );
    }
}

/**
 * Clear WordPress internal caches to free memory during batch operations.
 *
 * WordPress caches post objects, metadata, and terms in static arrays
 * that grow unbounded during long-running CLI operations.
 */
private function stop_the_insanity() {
    global $wpdb, $wp_object_cache;

    // Clear the post cache
    $wpdb->queries = [];

    // Reset the WordPress object cache if using the built-in cache
    if ( is_object( $wp_object_cache ) ) {
        $wp_object_cache->group_ops      = [];
        $wp_object_cache->memcache_debug = [];
        $wp_object_cache->cache          = [];

        if ( method_exists( $wp_object_cache, '__remoteset' ) ) {
            $wp_object_cache->__remoteset();
        }
    }

    // Clear static caches used by WordPress internally
    if ( function_exists( 'wp_cache_flush_runtime' ) ) {
        wp_cache_flush_runtime();
    }
}

The stop_the_insanity() method (a name popularized by 10up's engineering team) clears the internal WordPress object cache between batches. Without this, WordPress accumulates every post object, every metadata query result, and every term lookup in static arrays that never get garbage collected during a single PHP process.

Cursor-Based Pagination

Offset-based pagination (LIMIT ... OFFSET ...) has a performance problem: MySQL must scan and discard all rows before the offset. For very large datasets, use cursor-based pagination instead:

$last_id = 0;

while ( true ) {
    $post_ids = $wpdb->get_col( $wpdb->prepare(
        "SELECT ID FROM {$wpdb->posts}
        WHERE post_type = %s
        AND post_status = 'publish'
        AND ID > %d
        ORDER BY ID ASC
        LIMIT %d",
        $post_type, $last_id, $batch_size
    ) );

    if ( empty( $post_ids ) ) {
        break;
    }

    foreach ( $post_ids as $post_id ) {
        // Process each post
        $last_id = $post_id;
    }

    $this->stop_the_insanity();
}

This approach uses the indexed ID column as a cursor. The query is fast regardless of how deep into the dataset you are, because MySQL uses the primary key index to jump directly to the right starting point.

Parallel Processing with xargs

For CPU-bound operations, split the work across multiple WP-CLI processes:

# Get all post IDs into a file
wp post list --post_type=post --format=ids > /tmp/post-ids.txt

# Process 4 posts at a time across 4 parallel workers
cat /tmp/post-ids.txt | tr ' ' '\n' | \
  xargs -P 4 -I {} wp deploy process-single --post-id={}

# Alternative: split into ranges
TOTAL=$(wp post list --post_type=post --format=count)
CHUNK=$((TOTAL / 4))

for i in 0 1 2 3; do
  OFFSET=$((i * CHUNK))
  wp deploy update-seo-meta --batch-size=$CHUNK --offset=$OFFSET &
done
wait
echo "All workers finished"

Be careful with parallel processing and WordPress: each process loads a separate WordPress instance, which means separate database connections. Check your MySQL max_connections setting before spinning up too many workers.

Real-World Examples: Deployment, Data Cleanup, and Site Provisioning

Theory is useful, but working code that solves real problems is better. Here are three complete examples drawn from production deployments.

Example 1: Zero-Downtime Deployment Script

This script handles the full deployment lifecycle, including atomic symlink swaps for truly zero-downtime releases:

#!/bin/bash
set -euo pipefail

# Configuration
DEPLOY_DIR="/var/www/releases"
SHARED_DIR="/var/www/shared"
CURRENT_LINK="/var/www/current"
KEEP_RELEASES=5

RELEASE_DIR="${DEPLOY_DIR}/$(date +%Y%m%d%H%M%S)"

echo "Starting deployment to ${RELEASE_DIR}"

# Step 1: Create release directory and extract code
mkdir -p "${RELEASE_DIR}"
rsync -a --exclude='.git' /tmp/build/ "${RELEASE_DIR}/"

# Step 2: Link shared resources
ln -sf "${SHARED_DIR}/wp-config.php" "${RELEASE_DIR}/wp-config.php"
ln -sf "${SHARED_DIR}/uploads" "${RELEASE_DIR}/wp-content/uploads"
ln -sf "${SHARED_DIR}/.htaccess" "${RELEASE_DIR}/.htaccess"

# Step 3: Install Composer dependencies
cd "${RELEASE_DIR}"
composer install --no-dev --optimize-autoloader --no-interaction

# Step 4: Run pre-deployment WP-CLI checks against the NEW release
# (Use the new code but the existing database)
wp --path="${RELEASE_DIR}" deploy preflight

# Step 5: Enable maintenance mode on the CURRENT release
wp --path="${CURRENT_LINK}" maintenance-mode activate 2>/dev/null || true

# Step 6: Run database migrations
wp --path="${RELEASE_DIR}" deploy db migrate --yes

# Step 7: Atomic symlink swap (this is the actual zero-downtime moment)
ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}.tmp"
mv -Tf "${CURRENT_LINK}.tmp" "${CURRENT_LINK}"

# Step 8: Post-deployment tasks
wp --path="${CURRENT_LINK}" cache flush
wp --path="${CURRENT_LINK}" rewrite flush
wp --path="${CURRENT_LINK}" cron event run --due-now

# Step 9: Disable maintenance mode
wp --path="${CURRENT_LINK}" maintenance-mode deactivate

# Step 10: Cleanup old releases
cd "${DEPLOY_DIR}"
ls -dt */ | tail -n +$((KEEP_RELEASES + 1)) | xargs -r rm -rf

# Step 11: Verify
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://example.com/")
if [ "${HTTP_STATUS}" -ne 200 ]; then
    echo "ERROR: Site returned HTTP ${HTTP_STATUS}. Rolling back."
    # Rollback: point symlink to previous release
    PREV_RELEASE=$(ls -dt ${DEPLOY_DIR}/*/ | head -2 | tail -1)
    ln -sfn "${PREV_RELEASE}" "${CURRENT_LINK}.tmp"
    mv -Tf "${CURRENT_LINK}.tmp" "${CURRENT_LINK}"
    wp --path="${CURRENT_LINK}" deploy db rollback --yes
    wp --path="${CURRENT_LINK}" cache flush
    echo "Rolled back to ${PREV_RELEASE}"
    exit 1
fi

echo "Deployment complete. Release: ${RELEASE_DIR}"

The atomic symlink swap on line 7 (Step 7) is the key technique. The mv -Tf command replaces the symlink in a single filesystem operation. The web server follows the symlink, so it switches from the old release to the new release without any requests hitting a partially-deployed state.

Example 2: Data Cleanup Command

Over time, WordPress databases accumulate orphaned metadata, post revisions, trashed posts, and stale transients. This command cleans all of it:

<?php

namespace Acme\DeployTools;

use WP_CLI;
use WP_CLI\Utils;

class CleanupCommand {

    /**
     * Run all cleanup operations.
     *
     * ## OPTIONS
     *
     * [--yes]
     * : Skip confirmation prompts.
     *
     * [--dry-run]
     * : Show what would be deleted without actually deleting.
     *
     * @when after_wp_load
     */
    public function all( $args, $assoc_args ) {
        $dry_run = Utils\get_flag_value( $assoc_args, 'dry-run', false );
        $totals  = [];

        WP_CLI::log( 'Running full database cleanup...' );
        WP_CLI::log( '' );

        $totals['revisions']        = $this->clean_revisions( $assoc_args, $dry_run );
        $totals['orphaned_meta']    = $this->clean_orphaned_meta( $dry_run );
        $totals['trashed_posts']    = $this->clean_trashed_posts( $assoc_args, $dry_run );
        $totals['spam_comments']    = $this->clean_spam_comments( $dry_run );
        $totals['expired_transients'] = $this->clean_transients( $dry_run );
        $totals['orphaned_terms']   = $this->clean_orphaned_term_relationships( $dry_run );

        WP_CLI::log( '' );

        $table_data = [];
        $grand_total = 0;
        foreach ( $totals as $category => $count ) {
            $table_data[] = [
                'Category' => str_replace( '_', ' ', ucfirst( $category ) ),
                'Items'    => $count,
            ];
            $grand_total += $count;
        }

        Utils\format_items( 'table', $table_data, [ 'Category', 'Items' ] );

        $action = $dry_run ? 'Would remove' : 'Removed';
        WP_CLI::success( sprintf( '%s %d items total.', $action, $grand_total ) );
    }

    private function clean_revisions( $assoc_args, $dry_run ) {
        global $wpdb;

        $count = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'revision'"
        );

        if ( $count === 0 ) {
            WP_CLI::log( 'Post revisions: 0 found.' );
            return 0;
        }

        WP_CLI::log( sprintf( 'Post revisions: %d found.', $count ) );

        if ( ! $dry_run ) {
            $wpdb->query(
                "DELETE a, b, c
                FROM {$wpdb->posts} a
                LEFT JOIN {$wpdb->term_relationships} b ON a.ID = b.object_id
                LEFT JOIN {$wpdb->postmeta} c ON a.ID = c.post_id
                WHERE a.post_type = 'revision'"
            );
        }

        return $count;
    }

    private function clean_orphaned_meta( $dry_run ) {
        global $wpdb;

        $count = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$wpdb->postmeta} pm
            LEFT JOIN {$wpdb->posts} p ON pm.post_id = p.ID
            WHERE p.ID IS NULL"
        );

        WP_CLI::log( sprintf( 'Orphaned post meta: %d found.', $count ) );

        if ( ! $dry_run && $count > 0 ) {
            $wpdb->query(
                "DELETE pm FROM {$wpdb->postmeta} pm
                LEFT JOIN {$wpdb->posts} p ON pm.post_id = p.ID
                WHERE p.ID IS NULL"
            );
        }

        return $count;
    }

    private function clean_trashed_posts( $assoc_args, $dry_run ) {
        global $wpdb;

        $count = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'trash'"
        );

        WP_CLI::log( sprintf( 'Trashed posts: %d found.', $count ) );

        if ( ! $dry_run && $count > 0 ) {
            $ids = $wpdb->get_col(
                "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
            );
            foreach ( $ids as $id ) {
                wp_delete_post( $id, true );
            }
        }

        return $count;
    }

    private function clean_spam_comments( $dry_run ) {
        global $wpdb;

        $count = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$wpdb->comments}
            WHERE comment_approved = 'spam' OR comment_approved = 'trash'"
        );

        WP_CLI::log( sprintf( 'Spam/trashed comments: %d found.', $count ) );

        if ( ! $dry_run && $count > 0 ) {
            $wpdb->query(
                "DELETE FROM {$wpdb->comments}
                WHERE comment_approved = 'spam' OR comment_approved = 'trash'"
            );
        }

        return $count;
    }

    private function clean_transients( $dry_run ) {
        global $wpdb;

        $count = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$wpdb->options}
            WHERE option_name LIKE '_transient_timeout_%'
            AND option_value < UNIX_TIMESTAMP()"
        );

        WP_CLI::log( sprintf( 'Expired transients: %d found.', $count ) );

        if ( ! $dry_run && $count > 0 ) {
            $wpdb->query(
                "DELETE a, b FROM {$wpdb->options} a
                INNER JOIN {$wpdb->options} b
                    ON b.option_name = REPLACE(a.option_name, '_transient_timeout_', '_transient_')
                WHERE a.option_name LIKE '_transient_timeout_%'
                AND a.option_value < UNIX_TIMESTAMP()"
            );
        }

        return $count;
    }

    private function clean_orphaned_term_relationships( $dry_run ) {
        global $wpdb;

        $count = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
            LEFT JOIN {$wpdb->posts} p ON tr.object_id = p.ID
            WHERE p.ID IS NULL"
        );

        WP_CLI::log( sprintf( 'Orphaned term relationships: %d found.', $count ) );

        if ( ! $dry_run && $count > 0 ) {
            $wpdb->query(
                "DELETE tr FROM {$wpdb->term_relationships} tr
                LEFT JOIN {$wpdb->posts} p ON tr.object_id = p.ID
                WHERE p.ID IS NULL"
            );
        }

        return $count;
    }
}

Pair this with a scheduled GitHub Action for periodic maintenance:

name: Weekly Database Cleanup

on:
  schedule:
    - cron: '0 3 * * 0'  # Every Sunday at 3 AM UTC

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Run cleanup
        run: |
          ssh [email protected] "cd /var/www/current && wp deploy cleanup all --yes"

      - name: Report results
        run: |
          ssh [email protected] "cd /var/www/current && wp deploy cleanup all --dry-run --format=json" | \
            curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
              -H 'Content-Type: application/json' \
              -d "{\"text\": \"Weekly cleanup complete. Results: $(cat -)\"}"

Example 3: Automated Site Provisioning

When your agency onboards a new client, provisioning a WordPress installation involves dozens of steps. Automate all of them:

<?php

namespace Acme\DeployTools;

use WP_CLI;
use WP_CLI\Utils;

class ProvisionCommand {

    /**
     * Provision a new WordPress site for a client.
     *
     * ## OPTIONS
     *
     * <domain>
     * : The domain name for the new site.
     *
     * [--title=<title>]
     * : Site title.
     *
     * [--admin-email=<email>]
     * : Admin email address.
     *
     * [--theme=<theme>]
     * : Theme to install and activate.
     * ---
     * default: astra
     * ---
     *
     * [--plugins=<plugins>]
     * : Comma-separated list of plugins to install.
     *
     * [--multisite]
     * : Set up as a multisite installation.
     *
     * @when after_wp_load
     */
    public function site( $args, $assoc_args ) {
        $domain      = $args[0];
        $title       = $assoc_args['title'] ?? ucfirst( explode( '.', $domain )[0] );
        $admin_email = $assoc_args['admin-email'] ?? "admin@{$domain}";
        $theme       = $assoc_args['theme'];
        $plugins     = isset( $assoc_args['plugins'] )
            ? array_map( 'trim', explode( ',', $assoc_args['plugins'] ) )
            : $this->default_plugins();

        WP_CLI::log( WP_CLI::colorize( '%GProvisioning site:%n ' . $domain ) );
        WP_CLI::log( '' );

        // Step 1: Core settings
        WP_CLI::log( '1. Configuring core settings...' );
        update_option( 'blogname', $title );
        update_option( 'blogdescription', '' );
        update_option( 'admin_email', $admin_email );
        update_option( 'timezone_string', 'America/New_York' );
        update_option( 'date_format', 'F j, Y' );
        update_option( 'time_format', 'g:i a' );
        update_option( 'start_of_week', 1 );
        update_option( 'permalink_structure', '/%postname%/' );
        flush_rewrite_rules();
        WP_CLI::log( '   Core settings configured.' );

        // Step 2: Install and activate theme
        WP_CLI::log( sprintf( '2. Installing theme: %s...', $theme ) );
        WP_CLI::runcommand( sprintf( 'theme install %s --activate', $theme ), [
            'return' => false,
            'exit_error' => true,
        ] );

        // Step 3: Install plugins
        WP_CLI::log( sprintf( '3. Installing %d plugins...', count( $plugins ) ) );
        $progress = Utils\make_progress_bar( '   Installing plugins', count( $plugins ) );

        foreach ( $plugins as $plugin ) {
            WP_CLI::runcommand( sprintf( 'plugin install %s --activate', $plugin ), [
                'return' => false,
                'exit_error' => false,
            ] );
            $progress->tick();
        }
        $progress->finish();

        // Step 4: Remove default content
        WP_CLI::log( '4. Removing default content...' );
        $hello_world = get_page_by_path( 'hello-world', OBJECT, 'post' );
        if ( $hello_world ) {
            wp_delete_post( $hello_world->ID, true );
        }
        $sample_page = get_page_by_path( 'sample-page', OBJECT, 'page' );
        if ( $sample_page ) {
            wp_delete_post( $sample_page->ID, true );
        }
        wp_delete_comment( 1, true );
        WP_CLI::log( '   Default content removed.' );

        // Step 5: Create standard pages
        WP_CLI::log( '5. Creating standard pages...' );
        $pages = [
            'Home'            => [ 'template' => 'templates/home.php' ],
            'About'           => [],
            'Services'        => [],
            'Blog'            => [],
            'Contact'         => [],
            'Privacy Policy'  => [ 'content' => $this->privacy_policy_content() ],
            'Terms of Service' => [],
        ];

        $home_id = 0;
        $blog_id = 0;

        foreach ( $pages as $page_title => $options ) {
            $post_data = [
                'post_title'   => $page_title,
                'post_content' => $options['content'] ?? '',
                'post_status'  => 'publish',
                'post_type'    => 'page',
            ];

            $page_id = wp_insert_post( $post_data );

            if ( isset( $options['template'] ) ) {
                update_post_meta( $page_id, '_wp_page_template', $options['template'] );
            }

            if ( $page_title === 'Home' ) {
                $home_id = $page_id;
            }
            if ( $page_title === 'Blog' ) {
                $blog_id = $page_id;
            }

            WP_CLI::log( sprintf( '   Created page: %s (ID: %d)', $page_title, $page_id ) );
        }

        // Step 6: Set homepage and posts page
        WP_CLI::log( '6. Configuring reading settings...' );
        update_option( 'show_on_front', 'page' );
        update_option( 'page_on_front', $home_id );
        update_option( 'page_for_posts', $blog_id );

        // Step 7: Create navigation menu
        WP_CLI::log( '7. Creating navigation menu...' );
        $menu_id = wp_create_nav_menu( 'Primary Navigation' );
        $menu_pages = [ 'Home', 'About', 'Services', 'Blog', 'Contact' ];
        $order = 1;

        foreach ( $menu_pages as $page_title ) {
            $page = get_page_by_title( $page_title, OBJECT, 'page' );
            if ( $page ) {
                wp_update_nav_menu_item( $menu_id, 0, [
                    'menu-item-title'     => $page_title,
                    'menu-item-object'    => 'page',
                    'menu-item-object-id' => $page->ID,
                    'menu-item-type'      => 'post_type',
                    'menu-item-status'    => 'publish',
                    'menu-item-position'  => $order++,
                ] );
            }
        }

        // Step 8: Security hardening
        WP_CLI::log( '8. Applying security settings...' );
        update_option( 'default_comment_status', 'closed' );
        update_option( 'default_ping_status', 'closed' );
        update_option( 'users_can_register', 0 );
        WP_CLI::log( '   Comments disabled, registration disabled.' );

        // Step 9: Create the primary admin user
        WP_CLI::log( '9. Creating admin user...' );
        $password = wp_generate_password( 24, true, true );
        $user_id = wp_insert_user( [
            'user_login' => 'client-admin',
            'user_email' => $admin_email,
            'user_pass'  => $password,
            'role'       => 'administrator',
            'first_name' => 'Client',
            'last_name'  => 'Admin',
        ] );

        if ( ! is_wp_error( $user_id ) ) {
            WP_CLI::log( sprintf( '   Admin user created. Login: client-admin / %s', $password ) );
            WP_CLI::warning( 'Save these credentials securely. They will not be displayed again.' );
        }

        // Summary
        WP_CLI::log( '' );
        WP_CLI::log( WP_CLI::colorize( '%GProvisioning Summary%n' ) );
        WP_CLI::log( str_repeat( '-', 40 ) );
        WP_CLI::log( sprintf( 'Domain:   %s', $domain ) );
        WP_CLI::log( sprintf( 'Title:    %s', $title ) );
        WP_CLI::log( sprintf( 'Theme:    %s', $theme ) );
        WP_CLI::log( sprintf( 'Plugins:  %d installed', count( $plugins ) ) );
        WP_CLI::log( sprintf( 'Pages:    %d created', count( $pages ) ) );
        WP_CLI::log( str_repeat( '-', 40 ) );
        WP_CLI::success( 'Site provisioned successfully.' );
    }

    private function default_plugins() {
        return [
            'wordpress-seo',
            'redirection',
            'wp-super-cache',
            'wordfence',
            'updraftplus',
            'regenerate-thumbnails',
            'wp-mail-smtp',
        ];
    }

    private function privacy_policy_content() {
        return '<!-- wp:paragraph -->
<p>This privacy policy sets out how this website uses and protects any information that you provide.</p>
<!-- /wp:paragraph -->

<!-- wp:paragraph -->
<p>We are committed to ensuring that your privacy is protected. Should we ask you to provide certain information by which you can be identified when using this website, you can be assured that it will only be used in accordance with this privacy statement.</p>
<!-- /wp:paragraph -->';
    }
}

Run the full provisioning with a single command:

wp deploy provision site newclient.com \
  --title="New Client Inc" \
  --admin-email="[email protected]" \
  --theme=astra \
  --plugins="wordpress-seo,wp-super-cache,wordfence,updraftplus,wp-mail-smtp"

What took 45 minutes of clicking through the WordPress admin now takes 30 seconds.

Putting It All Together: A Production Workflow

Let me outline the full workflow that ties these pieces together. A developer on your team pushes code to the main branch. Here is what happens next, step by step:

1. GitHub Actions triggers the pipeline. The test job spins up a MySQL service container, installs PHP and WP-CLI, and runs the Behat test suite against your custom commands. If any scenario fails, the pipeline stops. No broken commands reach production.

2. The build job compiles assets. Node.js installs dependencies, Tailwind CSS compiles, JavaScript gets bundled and minified. The built artifacts are uploaded as a GitHub Actions artifact for the deploy job to pick up.

3. The deploy job connects to the server via SSH. It rsyncs the codebase (excluding uploads, cache, and configuration files), creates a new release directory, and links shared resources.

4. WP-CLI runs the deployment sequence. The wp deploy preflight command verifies the environment. The wp deploy db migrate command applies any new database migrations. Caches get flushed. Rewrite rules get regenerated.

5. The symlink swaps atomically. One filesystem operation switches the live site from the old release to the new one. Zero downtime. No partial states.

6. Post-deployment verification runs. An HTTP check confirms the site responds with a 200 status code. If not, an automatic rollback kicks in: the symlink reverts to the previous release, and the last migration gets rolled back.

7. Notifications go out. Slack gets a message with the deployment status, the commit SHA, and the environment name.

The entire process takes about two minutes from push to production. No FTP. No clicking through wp-admin. No crossing fingers and hoping nothing breaks.

WP-CLI Configuration Tips for CI/CD Environments

A few configuration details make WP-CLI work smoothly in automated environments.

Global Configuration

Create a wp-cli.yml in your project root:

path: /var/www/current
color: true
debug: false

@staging:
  ssh: [email protected]/var/www/staging
  color: false

@production:
  ssh: [email protected]/var/www/production
  color: false

disabled_commands:
  - db drop
  - site empty

Disabling dangerous commands prevents accidental execution in production. The color: false setting for remote aliases keeps CI logs clean since ANSI color codes render as garbage in most log viewers.

Environment Detection

Your commands should behave differently based on the environment. Here is a helper for that:

private function get_environment() {
    // Check for WP_ENV constant (used by Bedrock and others)
    if ( defined( 'WP_ENV' ) ) {
        return WP_ENV;
    }

    // Check for environment variable
    $env = getenv( 'WP_ENVIRONMENT_TYPE' );
    if ( $env ) {
        return $env;
    }

    // WordPress 5.5+ function
    if ( function_exists( 'wp_get_environment_type' ) ) {
        return wp_get_environment_type();
    }

    return 'production'; // Safe default
}

public function run( $args, $assoc_args ) {
    $env = $this->get_environment();

    if ( $env === 'production' ) {
        WP_CLI::confirm( 'You are deploying to PRODUCTION. Continue?', $assoc_args );
    }

    // Adjust behavior based on environment
    $enable_maintenance = ( $env === 'production' );
    $verbose_logging    = ( $env !== 'production' );

    // ...
}

Memory and Time Limits

CLI processes should have more generous limits than web requests:

// In your command's constructor or at the top of long-running methods
if ( defined( 'WP_CLI' ) && WP_CLI ) {
    ini_set( 'memory_limit', '512M' );
    set_time_limit( 0 ); // No timeout for CLI operations
}

// Or set it globally in wp-cli.yml
# wp-cli.yml
apache_modules:
  - mod_rewrite

# PHP settings for CLI context
exec:
  php: |
    ini_set('memory_limit', '512M');
    set_time_limit(0);

Testing Your CI/CD Pipeline Locally

Before pushing to GitHub and waiting for Actions to run, test the pipeline locally using act (a tool that runs GitHub Actions workflows on your machine):

# Install act
brew install act

# Run the full workflow
act push --secret-file .env.ci

# Run a specific job
act push -j deploy --secret-file .env.ci

# List available workflows
act -l

Create a .env.ci file with test values for your secrets (never commit this file):

DEPLOY_SSH_KEY=...
DEPLOY_KNOWN_HOSTS=...
STAGING_HOST=localhost
STAGING_PATH=/tmp/test-deploy
SLACK_WEBHOOK=https://hooks.slack.com/test

This catches configuration errors, missing secrets, and broken scripts before they hit your actual CI environment.

Monitoring and Maintenance After Deployment

Deployment is not the end of the story. Set up ongoing monitoring using WP-CLI commands triggered by cron or scheduled CI jobs:

# Daily health check
wp deploy verify --format=json | jq '.status'

# Weekly database optimization
wp db optimize

# Check for available updates
wp plugin list --update=available --format=count
wp theme list --update=available --format=count
wp core check-update --format=json

# Monitor database size
wp db size --tables --format=table

# Check cron health
wp cron event list --format=table
wp cron test

Pipe these into your monitoring system. If wp deploy verify returns a non-zero exit code, trigger an alert. If available updates exceed a threshold, create a Jira ticket or Slack notification. The point is to use WP-CLI as the universal interface between WordPress and your operations infrastructure.

WP-CLI transforms WordPress from a GUI-first CMS into a platform that fits naturally into modern DevOps workflows. The investment in building custom commands and CI/CD pipelines pays for itself the first time a deployment goes wrong and your automated rollback catches it in seconds rather than minutes. Start with one command that automates your most painful manual process. Expand from there. Every command you add is one less thing that can go wrong at 2 AM on a Friday.

Share this article

Tom Bradley

DevOps engineer focused on WordPress deployment automation. Builds CI/CD pipelines and infrastructure-as-code solutions for WordPress agencies.