WP-CLI for CI/CD: Custom Commands, Package Development, and Deployment Automation
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:
- Push your package to a public GitHub repository.
- Ensure
composer.jsonhas the correctname,type: "wp-cli-package", and a version tag. - Register the package on packagist.org.
- 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= success1= general error (whatWP_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.
Tom Bradley
DevOps engineer focused on WordPress deployment automation. Builds CI/CD pipelines and infrastructure-as-code solutions for WordPress agencies.