## What it is

WP-CLI is the command-line interface for WordPress — every action you can perform in the wp-admin dashboard (install plugins, update core, edit options, run a multisite network) has a `wp` subcommand that does the same thing without a browser. For deployment workflows that means treating WordPress like any other PHP app: install plugins from `composer.json`, swap URLs after a database restore, flush caches in a post-deploy hook, and never SSH into a host to "just fix this one option" again.

This sheet covers the commands and flags you reach for when wiring WordPress into a CI/CD pipeline — the `search-replace` and `db export` patterns that survive a staging-to-production migration, the plugin/theme update flows that don't require a browser session, and the cache discipline that keeps a multi-environment WordPress fleet predictable.

## Quick reference

### Installing WP-CLI and sanity checks

```bash
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp                   # global install

wp --info                                               # PHP version, MySQL, WP root
wp cli version                                          # WP-CLI version
wp cli update                                           # self-update
wp cli check-update                                     # check without installing
wp doctor list                                          # diagnostics (needs wp-cli/doctor-command)
```

Useful environment defaults — set once per server or per CI step:

```bash
export WP_CLI_PHP=/usr/bin/php8.2                       # pin a specific PHP binary
export WP_CLI_PHP_ARGS="-d memory_limit=512M"           # increase memory for bulk ops
export WP_CLI_ALLOW_ROOT=1                              # CI runs as root — opt in explicitly
```

### Site bootstrap

```bash
wp core download                                        # download WP into the cwd
wp core download --version=6.5.4 --locale=en_GB
wp config create \
  --dbname=app --dbuser=app --dbpass=secret --dbhost=db --dbprefix=wp_

wp core install \
  --url=example.com --title="My Site" \
  --admin_user=admin --admin_email=team@example.com --admin_password=PASSWORD --skip-email

wp core is-installed                                    # exit 0 if installed (CI-friendly)
wp core verify-checksums                                # detect tampered core files
```

### Users

```bash
wp user list                                            # interactive table
wp user list --role=administrator --field=user_email --format=csv
wp user create alice alice@example.com --role=editor --user_pass=PASSWORD --send-email=false
wp user update 42 --user_pass="$NEW_PASSWORD"
wp user delete 42 --reassign=1                          # reassign content to user 1
wp user one-time-login alice                            # one-shot magic link (debugging only)
```

### Plugins

```bash
wp plugin list                                          # name, status, version, update
wp plugin list --status=active --field=name
wp plugin install wp-super-cache --activate
wp plugin install https://example.com/plugin.zip --activate
wp plugin install /path/to/plugin.zip --activate        # from local file
wp plugin update --all --quiet
wp plugin update wp-super-cache --version=1.13.0
wp plugin activate wp-super-cache
wp plugin deactivate wp-super-cache
wp plugin delete wp-super-cache
wp plugin is-installed wp-super-cache                   # exit 0 if installed
wp plugin path wp-super-cache                           # absolute path
```

### Themes

```bash
wp theme list
wp theme install twentytwentyfour --activate
wp theme install /path/to/theme.zip --activate
wp theme update --all
wp theme activate my-child-theme
wp theme delete inactive-theme
wp theme mod list                                       # current customizer settings
wp theme mod set background_color FFFFFF
```

### Posts, pages, and taxonomies

```bash
wp post list --post_type=page --field=ID
wp post list --post_status=publish --posts_per_page=20 --format=table

wp post create --post_type=post --post_title="Release notes" --post_status=publish \
  --post_content="$(cat changelog.md)"

wp post update 42 --post_status=draft
wp post delete 42 --force                               # bypass trash
wp post meta set 42 _custom_field "value"

wp term list category --field=slug
wp term create category "Releases" --slug=releases --description="Release notes"
```

### Options, transients, and rewrite rules

```bash
wp option get siteurl
wp option get blogname
wp option update blogname "Production"
wp option update siteurl "https://example.com"
wp option update home    "https://example.com"
wp option pluck rewrite_rules                           # dump nested option values

wp transient delete --all                               # nuke all transients
wp transient delete some_key

wp rewrite flush                                        # rebuild permalink rules
wp rewrite structure '/%postname%/'                     # set permalink structure
```

### Database

```bash
wp db check                                             # mysqlcheck
wp db optimize
wp db repair
wp db size --tables
wp db tables                                            # list with prefix
wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_status='publish';"

wp db export backup.sql                                 # mysqldump → file
wp db export - | gzip > backup.sql.gz                   # streamed/compressed
wp db export --tables=wp_posts,wp_postmeta posts.sql

wp db import backup.sql
wp db reset --yes                                       # DROP + CREATE all tables
```

### search-replace (the one you'll use most)

```bash
wp search-replace 'http://staging.example.com' 'https://example.com'
wp search-replace 'old.example.com' 'new.example.com' --dry-run
wp search-replace 'old' 'new' --all-tables-with-prefix
wp search-replace 'old' 'new' --skip-columns=guid       # NEVER touch guid (it's an identifier)
wp search-replace 'old' 'new' --precise                 # slower but correct for serialized PHP
wp search-replace 'old' 'new' --export=migrated.sql     # dump replacement, don't write DB
```

`wp search-replace` handles serialized PHP correctly — a plain `sed` on a `mysqldump` corrupts every serialized array length prefix and breaks the site. Always use this for URL migrations.

### Cache and OPcache

```bash
wp cache flush                                          # object cache (Redis/Memcached)
wp cache flush-group some_group                         # WP 6.x+
wp transient delete --all
wp rewrite flush

wp eval 'if (function_exists("opcache_reset")) opcache_reset();'   # PHP OPcache
wp eval 'echo wp_get_environment_type();'               # production/staging/development
```

### Multisite

```bash
wp site list --field=url
wp site create --slug=blog --title="Marketing" --email=team@example.com
wp site delete 3 --yes
wp site activate 3
wp site deactivate 3

wp site option update 3 blogname "Marketing"
wp super-admin list
wp super-admin add admin

# Run a command across every subsite
for url in $(wp site list --field=url); do
  wp --url="$url" plugin update --all
done
```

### Cron events

```bash
wp cron event list
wp cron event run --due-now
wp cron event run my_custom_hook
wp cron event delete my_custom_hook
wp cron test                                            # verify wp-cron reachability
```

For production: disable `DISABLE_WP_CRON` in `wp-config.php` and call `wp cron event run --due-now` from a real cron job — see the [Cron and Crontab cheatsheet](https://www.deployhq.com/cheatsheets/cron) for the schedule pattern.

### REST API and roles

```bash
wp rest list --format=table                             # endpoints + capabilities
wp role list
wp role create editor_lite "Editor Lite" --clone=editor
wp cap list editor_lite
wp cap add editor_lite manage_options
```

---

## Deployment workflows (the moat)

### 1. URL migrations after a staging-to-production restore

Restoring a staging database into production is a common deploy pattern (or recovery pattern after a botched release) — and every internal URL needs swapping before the site is usable. Don't do this with `sed`; serialised PHP corrupts.

```bash
#!/usr/bin/env bash
set -euo pipefail

# 1. Pull the staging dump
ssh staging "wp db export - --add-drop-table" | gunzip > /tmp/staging.sql

# 2. Restore on production (after a backup — see workflow 2)
wp db reset --yes
wp db import /tmp/staging.sql

# 3. Swap URLs — dry-run first
wp search-replace 'https://staging.example.com' 'https://example.com' \
  --all-tables-with-prefix --skip-columns=guid --dry-run

# 4. Real swap if dry-run looked sane
wp search-replace 'https://staging.example.com' 'https://example.com' \
  --all-tables-with-prefix --skip-columns=guid

# 5. Flush caches
wp cache flush
wp rewrite flush
wp transient delete --all
```

Why `--skip-columns=guid`: the `guid` column is meant to be a *permanent* identifier for each post — RSS readers and external systems use it as a dedupe key. Rewriting it breaks subscriptions in ways that take weeks to notice.

Why `--all-tables-with-prefix`: plugins routinely serialise URLs into `wp_options` AND into their own tables (`wp_yoast_seo_links`, `wp_woocommerce_*`). The default `wp search-replace` only walks the core tables — `--all-tables-with-prefix` walks every table with the configured prefix, which is what you actually want.

### 2. Pre-deploy database export as an atomic gate

Before any deploy that touches schema or runs a migration, take a snapshot — and make sure it actually wrote:

```bash
#!/usr/bin/env bash
set -euo pipefail

BACKUP="/var/backups/wp/$(date -u +%Y%m%dT%H%M%SZ).sql.gz"
mkdir -p "$(dirname "$BACKUP")"

# Export streamed through gzip — no temporary uncompressed dump on disk
wp db export - --add-drop-table | gzip -9 > "$BACKUP"

# Verify the dump is non-empty AND contains expected DDL
test -s "$BACKUP" || { echo "backup is empty" >&2; exit 1; }
gunzip -c "$BACKUP" | grep -q 'CREATE TABLE.*wp_options' \
  || { echo "backup missing wp_options DDL" >&2; exit 1; }

# Keep last 7 days
find /var/backups/wp -name '*.sql.gz' -mtime +7 -delete

echo "backup verified: $BACKUP"
```

The `grep` step catches the failure mode that bites everyone eventually: `wp db export` succeeds (exit 0) but produces a tiny dump because the DB credentials reference an empty database. A `test -s` alone doesn't catch that. Confirming a known table appears in the dump does.

For DeployHQ deploys, wire this as a pre-deploy SSH command — failing here aborts the deploy before any code changes, so production is never sitting on an "almost deployed" half-state.

### 3. Plugin and theme updates from CI

WordPress sites in production should not be running "Update available" prompts. Either updates flow through Composer (preferred — the `vendor/` directory is the source of truth), or through WP-CLI in a deploy step. Either way, never click "Update" in wp-admin.

```bash
#!/usr/bin/env bash
set -euo pipefail
export WP_CLI_ALLOW_ROOT=1

# Snapshot DB before bulk updates — see workflow 2
/usr/local/bin/pre-deploy-backup.sh

# Core
wp core update --version=6.5.4 --quiet
wp core update-db                                       # idempotent, safe to re-run

# All plugins, exclude maintenance-mode-sensitive ones
wp plugin update --all --exclude=wp-super-cache,wordfence --quiet

# Specific plugin to a pinned version
wp plugin update yoast-seo --version=22.4 --quiet

# Themes
wp theme update --all --quiet

# Cleanup
wp cache flush
wp rewrite flush
wp transient delete --all

# Smoke-test the homepage post-update
curl -fsS -o /dev/null -w "%{http_code}\n" https://example.com/ | grep -q '^200$' \
  || { echo "homepage non-200 after update" >&2; exit 1; }
```

The smoke-test step matters: a plugin update can put the site into a fatal error state where `wp` itself still works but every page returns 500. Curling the homepage from inside the deploy script catches that before traffic gets routed back.

### 4. Multisite deploys without forgetting a subsite

In a multisite network, every command needs the right `--url` flag — and "I'll run it on the main site, it should propagate" is the classic source of half-migrated networks. Pattern that holds:

```bash
#!/usr/bin/env bash
set -euo pipefail

# Network-wide plugin install (NOT per-subsite)
wp plugin install advanced-custom-fields-pro --activate-network

# Per-subsite operations — iterate explicitly
mapfile -t URLS < <(wp site list --field=url)

for url in "${URLS[@]}"; do
  echo "::group::$url"
  wp --url="$url" plugin update --all --quiet
  wp --url="$url" cache flush
  wp --url="$url" rewrite flush
  echo "::endgroup::"
done
```

`--activate-network` is the multisite-aware activation flag — it activates the plugin for every subsite AND prevents per-subsite activation. Forgetting it on a security-related plugin (Wordfence, iThemes Security) is the kind of mistake that takes a postmortem to find.

The `mapfile` pattern captures all URLs into a Bash array *before* the loop runs — so if one subsite's update kills the wp-cron daemon mid-loop, you still have the list of remaining subsites to retry.

### 5. Cache and OPcache flush as the last deploy step

A WordPress deploy that doesn't flush all four cache layers ends up serving stale content from somewhere. The four layers, in flush order:

```bash
# 1. Object cache (Redis/Memcached) — invalidates wp_options, transients
wp cache flush

# 2. Page cache (whichever plugin is installed)
wp super-cache flush 2>/dev/null || true                # wp-super-cache
wp cache-enabler clear 2>/dev/null || true              # cache-enabler
wp rocket clean --confirm 2>/dev/null || true           # wp-rocket
wp w3-total-cache flush all 2>/dev/null || true         # w3-total-cache

# 3. Permalink rewrite rules (always last among DB caches)
wp rewrite flush --hard

# 4. PHP OPcache — files on disk changed but OPcache still has the old bytecode
wp eval 'if (function_exists("opcache_reset")) opcache_reset();'
# Or, if PHP-FPM:
sudo systemctl reload php8.2-fpm

# 5. CDN / edge cache (Cloudflare, BunnyCDN, etc.) — outside WP, fire via curl/API
```

The `2>/dev/null || true` pattern lets the script run on any host without knowing in advance which cache plugin is active — it tries each and ignores the ones that aren't installed. That's slightly less explicit than checking with `wp plugin is-installed` first, but the script stays portable across staging and production when they happen to run different cache plugins.

For an atomic deploy that uses [zero-downtime deployments](https://www.deployhq.com/features/zero-downtime-deployments), OPcache flush is the most important step — without it the new release directory is live (the symlink swapped) but PHP-FPM is still executing the old bytecode it cached against the previous release path. Reload php-fpm or call `opcache_reset()` from the post-deploy hook.

### 6. Composer-managed WordPress sites

For sites where Composer is the source of truth (Roots/Bedrock, custom setups), WP-CLI's role narrows to runtime tasks — DB ops, search-replace, cron, cache flush — while plugin/theme installs flow through `composer install`. The deploy script looks like:

```bash
#!/usr/bin/env bash
set -euo pipefail

# Pull code + install vendor/ on the BUILD host (not production)
composer install --no-dev --prefer-dist --optimize-autoloader --classmap-authoritative

# On the production host, after symlink swap:
wp core update-db --quiet                               # safe if no DB changes
wp cache flush
wp rewrite flush --hard
wp eval 'opcache_reset();'
```

See the [Composer cheatsheet](https://www.deployhq.com/cheatsheets/composer) for the production install flags and the `composer.lock` discipline that makes this work.

---

## Common errors and fixes

| Error / symptom | Cause | Fix |
|---|---|---|
| `Error: This does not seem to be a WordPress installation` | Running `wp` outside the WP root | `cd` to WP root or use `--path=/var/www/current` |
| `Error establishing a database connection` from WP-CLI but wp-admin works | `wp-config.php` references `localhost` but DB is on a Unix socket | Add `--dbhost=localhost:/var/run/mysqld/mysqld.sock` to config |
| `Error: YIKES! It looks like you're running this as root` | Default safety guard | Set `WP_CLI_ALLOW_ROOT=1` in CI, or use `sudo -u www-data wp ...` |
| `Error: Couldn't connect to localhost:80` (network plugins) | Plugin tries to call its own REST API during activation | Run with `--skip-plugins=<offender>` for activation, then re-enable |
| `search-replace` runs forever on a large site | Each row touched even if nothing matched | Add `--include-columns=post_content,post_excerpt` to scope |
| `search-replace` mangles serialized data | `--precise` not set | Always add `--precise` for serialised data; default scan misses some edge cases |
| `Fatal error: Allowed memory size exhausted` | PHP CLI memory limit too low | `WP_CLI_PHP_ARGS="-d memory_limit=512M" wp ...` |
| `wp cron event run` never fires events | `DISABLE_WP_CRON` true but no system cron set | Add `wp cron event run --due-now` to system crontab (see Cron cheatsheet) |
| Multisite: `wp option get` returns site #1's value | Missing `--url` flag — defaults to main site | Add `--url=https://blog.example.com/` |
| `Plugin update failed: download failed` | Outbound HTTPS blocked or DNS broken | Test `curl -fsS https://downloads.wordpress.org/`; whitelist on firewall |
| Site shows old content after deploy | OPcache not flushed | `wp eval 'opcache_reset();'` or `systemctl reload php-fpm` |
| `wp` command not found in cron | Cron's `PATH` is minimal | Use full path `/usr/local/bin/wp` in cron lines |

---

## Companion: full DeployHQ deploy workflow

A WordPress deploy is the same atomic-release pattern as any PHP app, with one extra concern: the database typically lives outside the release directory, so schema changes (plugin updates, core upgrades) are *durable* — you can't roll them back by swapping the symlink. That's what makes the pre-deploy `wp db export` (workflow 2) the load-bearing step in this whole sheet.

For the end-to-end pattern — GitHub push → CI build (composer install) → DeployHQ webhook → SSH deploy hooks (the wp-cli steps above) → smoke test → symlink swap — wire it through [DeployHQ's build pipelines](https://www.deployhq.com/features/build-pipelines) and the [deploy from GitHub guide](https://www.deployhq.com/deploy-from-github). If a release does ship a bad plugin update, [one-click rollback](https://www.deployhq.com/features/one-click-rollback) reverts the file tree while you `wp db import` the pre-deploy backup.

[Start a free DeployHQ trial](https://www.deployhq.com/signup) to wire `wp-cli` into a production deploy pipeline in minutes.

---

## Related cheatsheets

- [Composer cheatsheet](https://www.deployhq.com/cheatsheets/composer) — for the `composer install --no-dev` step that ships your plugins and themes.
- [Cron and Crontab cheatsheet](https://www.deployhq.com/cheatsheets/cron) — for replacing WordPress's traffic-driven `wp-cron` with a real system cron.
- [Bash cheatsheet](https://www.deployhq.com/cheatsheets/bash) — for the `set -euo pipefail` scripts that orchestrate the WP-CLI steps above.
- [SSH cheatsheet](https://www.deployhq.com/cheatsheets/ssh) — for the deploy keys and `~/.ssh/config` patterns the deploy script runs over.
- [Docker cheatsheet](https://www.deployhq.com/cheatsheets/docker) — for the container patterns when WP-CLI runs inside a containerised PHP stack.
- [Cheatsheets hub](https://www.deployhq.com/cheatsheets) — every DeployHQ cheatsheet in one place.

---

Need help? Email [support@deployhq.com](mailto:support@deployhq.com) or follow [@deployhq on X](https://x.com/deployhq).