wordpress
WP-CLI operations at scale — running 200 WordPress sites from one terminal
The WP-CLI patterns we use to operate hundreds of WordPress sites without losing our minds, including the multi-site loop, the dry-run discipline, and the audit script.
27 mai 2026 · 8 min · par Sudhanshu K.
WP-CLI operations at scale — running 200 WordPress sites from one terminal
WP-CLI is one of the most under-appreciated tools in the WordPress ecosystem. For a single site, it saves you a few clicks. For a hundred sites, it's the difference between "we have an ops team" and "we don't have an ops function at all."
This post is about the WP-CLI patterns we use on the EdgeServers managed WordPress fleet to keep hundreds of customer sites coherent without anyone needing to log in to a dashboard. Most of these patterns generalize — if you're running 10 WordPress sites, the same techniques apply.
The site inventory
Everything starts with a flat file inventory of sites, paths, and their hosts:
# /etc/edge/wp-fleet.tsv
customer1 webA.example.com /var/www/customer1
customer2 webA.example.com /var/www/customer2
customer3 webB.example.com /var/www/customer3
customer4 webC.example.com /var/www/customer4
...
This is the source of truth that everything else iterates over. We keep it in git, versioned. Nothing happens to a customer site that doesn't start with adding a row here.
The wrapper script
You don't run wp directly across the fleet. You run a wrapper that handles the SSH, the path, and the user context:
#!/bin/bash
# /usr/local/bin/wp-fleet
# Usage: wp-fleet <customer> <wp-cli command...>
CUSTOMER=$1; shift
LINE=$(grep "^${CUSTOMER}\b" /etc/edge/wp-fleet.tsv)
[ -z "$LINE" ] && { echo "No such customer: $CUSTOMER"; exit 1; }
HOST=$(echo "$LINE" | awk '{print $2}')
PATH_=$(echo "$LINE" | awk '{print $3}')
ssh -q "$HOST" "sudo -u www-data wp --path=$PATH_ $*"Now a one-liner that hits a single site looks like:
wp-fleet customer1 plugin list
wp-fleet customer1 core check-update
wp-fleet customer1 db query "SELECT COUNT(*) FROM wp_users"And a fleet-wide loop looks like:
for c in $(awk '{print $1}' /etc/edge/wp-fleet.tsv); do
echo "==== $c ===="
wp-fleet "$c" core version
doneThat's it. That's the entire ops primitive. Everything below is recipes that compose on top of this.
Recipe 1: Fleet-wide WordPress core version audit
Every Monday morning, the cron runs:
#!/bin/bash
echo "customer,core_version,latest,status" > /tmp/core-audit.csv
for c in $(awk '{print $1}' /etc/edge/wp-fleet.tsv); do
current=$(wp-fleet "$c" core version 2>/dev/null)
check=$(wp-fleet "$c" core check-update --format=json 2>/dev/null)
latest=$(echo "$check" | jq -r '.[0].version // "current"')
status=$([ "$current" = "$latest" ] || [ "$latest" = "current" ] && echo "ok" || echo "stale")
echo "$c,$current,$latest,$status" >> /tmp/core-audit.csv
doneThis runs in about 90 seconds against 200 sites and gives us a CSV of every site, its WordPress version, the latest WordPress version, and whether it's stale. We pipe it into our internal dashboard.
The point isn't the CSV. The point is that we can answer the question "which of our customers are running stale WordPress?" in 90 seconds, not 200 dashboard logins. This is the entire bargain of managed hosting that's actually managed — fleet operations should be a script, not a checklist.
Recipe 2: Plugin install/update with dry-run discipline
Never, ever, run a fleet-wide plugin update without first running a dry-run pass that shows you exactly what will change:
# Dry run
for c in $(awk '{print $1}' /etc/edge/wp-fleet.tsv); do
echo "==== $c ===="
wp-fleet "$c" plugin list --update=available --format=table
done | tee /tmp/update-plan.log
# Read /tmp/update-plan.log carefully
less /tmp/update-plan.log
# Real run, only for sites that pass the plan review
for c in customer1 customer3 customer14 customer29; do
wp-fleet "$c" plugin update --all
wp-fleet "$c" cli cache clear
doneNote we update one customer at a time, not all at once. If a plugin update breaks a site, we want to know on customer1 before we propagate the breakage to customers 2 through 200. Cheap, slow, and correct.
Recipe 3: Bulk user audit
The single most common compliance request we get: "show me every admin account across every site." 30 seconds of WP-CLI:
echo "customer,user_login,user_email,roles,last_login" > /tmp/admin-audit.csv
for c in $(awk '{print $1}' /etc/edge/wp-fleet.tsv); do
wp-fleet "$c" user list --role=administrator --format=csv \
--fields=user_login,user_email,roles 2>/dev/null \
| tail -n +2 \
| awk -v c="$c" '{print c","$0}' >> /tmp/admin-audit.csv
doneThis is the kind of thing that takes a junior engineer half a day in a dashboard, and that genuinely useful tools like WP-CLI do in half a minute. Combined with last_login_time user meta from a wp user meta get follow-up loop, we can quickly identify stale admin accounts that haven't been touched in a year and need to be revoked.
Recipe 4: Search-replace at scale
When a customer rebrands, you need to find every reference to the old domain across every database table — including inside serialized PHP arrays. WP-CLI's search-replace handles this correctly:
wp-fleet customer1 search-replace 'old-domain.com' 'new-domain.com' \
--all-tables \
--skip-columns=guid \
--dry-run
# Review the count
# Then:
wp-fleet customer1 search-replace 'old-domain.com' 'new-domain.com' \
--all-tables \
--skip-columns=guidThe --skip-columns=guid is important. The guid column in wp_posts is the WordPress permalink-at-creation; it's not used for navigation and changing it can break RSS subscribers' history.
Recipe 5: Maintenance mode with a coordinated window
When you're doing something invasive across the fleet (PHP version upgrade on the underlying server, MySQL version upgrade, kernel reboot), you want every site in maintenance mode briefly:
for c in $(awk '{print $1}' /etc/edge/wp-fleet.tsv); do
wp-fleet "$c" maintenance-mode activate
done
# ... do the thing ...
for c in $(awk '{print $1}' /etc/edge/wp-fleet.tsv); do
wp-fleet "$c" maintenance-mode deactivate
doneA more refined version of this pages the on-call if any site is still in maintenance mode 30 minutes after the window closed — usually it means a deploy script crashed mid-way through.
Recipe 6: Health checks before close-of-business
Last thing we run each evening:
for c in $(awk '{print $1}' /etc/edge/wp-fleet.tsv); do
result=$(wp-fleet "$c" doctor check --all --format=json 2>/dev/null)
failures=$(echo "$result" | jq '[.[] | select(.status=="error")] | length')
if [ "$failures" -gt 0 ]; then
echo "$c: $failures failed checks"
echo "$result" | jq '.[] | select(.status=="error")'
fi
donewp doctor is the WordPress equivalent of a smoke test — it runs ~30 checks for things like core file integrity, plugin issues, database integrity, and theme problems. We page on anything that's both new (didn't fail yesterday) and a customer-visible issue.
What doesn't work
A few patterns we've tried and abandoned:
- Parallel
xargs -Ploops against the fleet. Hammering the database server with 50 concurrentwpinvocations is a great way to cause the outage you're trying to detect. We serialize. - Caching WP-CLI output for the dashboard. We tried, then realised stale data is worse than slow data. The dashboard queries the live fleet on demand, with results memoized for 5 minutes.
- WP-CLI from cron on the WordPress server itself, instead of via SSH. Tempting, but you lose the central audit trail and the ability to roll out a change to the orchestration script without touching every host.
Where this leads
Once you have the wrapper + the inventory + a half-dozen recipes, you stop thinking of WordPress operations as a per-site task. The unit of operation becomes "the fleet." A new plugin advisory drops — you check the fleet. A vulnerability is published — you check the fleet. A customer asks "are we patched against X?" — you check the fleet, in front of them.
That's what serious WordPress management looks like in 2026, and it's why our managed WordPress customers on Azure, AWS, and elsewhere don't have to think about any of this. It's just done.
Drop us a line if your WordPress estate has grown past the "a few dashboards" stage and you're feeling it.
Sudhanshu K. is a Senior Site Reliability Engineer at EdgeServers (RemotIQ Pty Ltd, ABN 91 682 628 128). She wrote her first WP-CLI wrapper script in 2014 and has been refining it ever since.