Bulk Operations Across WordPress Multisite Networks with WP-CLI

WordPress Multisite networks power some of the web’s largest publishing platforms, educational institutions, and enterprise content systems. Managing dozens or hundreds of sites individually is impractical. WP-CLI provides essential tools for executing bulk operations across entire networks efficiently and consistently.

Understanding WordPress Multisite Architecture

WordPress Multisite transforms a single WordPress installation into a network of sites sharing core files, plugins, and themes while maintaining separate databases for content. This architecture enables centralized management but requires specialized tools for network-wide operations.

Each site in a multisite network has a unique site ID, URL, and database tables. Network administrators need methods to iterate through sites, apply configurations selectively, and verify operations across the entire network. WP-CLI’s multisite commands make these complex operations manageable.

Basic Multisite Navigation

Before performing bulk operations, understand how to navigate and query your multisite network.

# List all sites in the network
wp site list

# List sites with specific columns
wp site list --fields=blog_id,url,registered

# Count total sites
wp site list --format=count

# Get specific site details
wp site list --site__in=1,5,10

# Filter sites by URL pattern
wp site list --url='*.example.com'

# Find archived/deleted/spam sites
wp site list --archived=true
wp site list --deleted=true
wp site list --spam=true

Get detailed information about a specific site:

# Get site URL
wp site url 5

# Get site domain and path
wp site list --field=url --blog_id=5

# Check if site is public
wp option get blog_public --url=example.com/site

Iterating Through Network Sites

The foundation of bulk operations is iterating through all sites and executing commands for each.

#!/bin/bash
# Basic site iteration example

# Get all site URLs
for url in $(wp site list --field=url); do
    echo "Processing: $url"
    wp --url="$url" option get blogname
done

# Using site IDs
for site_id in $(wp site list --field=blog_id); do
    echo "Processing site ID: $site_id"
    SITE_URL=$(wp site url $site_id)
    wp --url="$SITE_URL" plugin list --status=active
done

Advanced iteration with error handling:

#!/bin/bash
# iterate-sites.sh - Robust site iteration with error handling

set -euo pipefail

LOG_FILE="/var/log/multisite-operations-$(date +%Y%m%d).log"
SUCCESS_COUNT=0
FAILURE_COUNT=0

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

process_site() {
    local url=$1
    log "Processing: $url"

    # Execute operation with error handling
    if wp --url="$url" cache flush 2>&1 | tee -a "$LOG_FILE"; then
        ((SUCCESS_COUNT++))
        log "SUCCESS: $url"
    else
        ((FAILURE_COUNT++))
        log "FAILED: $url"
    fi
}

# Main execution
log "Starting network-wide operation"

# Get all active sites (exclude archived, deleted, spam)
SITES=$(wp site list --archived=false --deleted=false --spam=false --field=url)

TOTAL=$(echo "$SITES" | wc -l)
log "Total sites to process: $TOTAL"

# Process each site
echo "$SITES" | while read -r site_url; do
    process_site "$site_url"
done

log "Operation completed. Success: $SUCCESS_COUNT, Failed: $FAILURE_COUNT"

Network-Wide Plugin Management

Manage plugins across all sites in your network consistently.

# Network activate a plugin (available to all sites)
wp plugin install jetpack --activate-network

# Network deactivate a plugin
wp plugin deactivate jetpack --network

# Check plugin status across all sites
for url in $(wp site list --field=url); do
    echo "Site: $url"
    wp --url="$url" plugin list --status=active --fields=name,version
done

# Activate plugin on all sites
for url in $(wp site list --field=url); do
    wp --url="$url" plugin activate woocommerce
    echo "Activated on: $url"
done

# Deactivate plugin from specific sites
for url in $(wp site list --field=url); do
    if [[ "$url" == *"store"* ]]; then
        echo "Keeping plugin on: $url"
    else
        wp --url="$url" plugin deactivate woocommerce
        echo "Deactivated on: $url"
    fi
done

Selective plugin deployment based on site characteristics:

#!/bin/bash
# deploy-plugin-conditional.sh - Deploy plugin based on conditions

PLUGIN_SLUG="custom-plugin"
REQUIRED_THEME="storefront"

echo "Deploying $PLUGIN_SLUG to sites using $REQUIRED_THEME theme"

for url in $(wp site list --field=url); do
    # Get active theme for site
    ACTIVE_THEME=$(wp --url="$url" theme list --status=active --field=name)

    if [ "$ACTIVE_THEME" == "$REQUIRED_THEME" ]; then
        echo "Activating $PLUGIN_SLUG on $url"
        wp --url="$url" plugin activate "$PLUGIN_SLUG"

        # Configure plugin settings
        wp --url="$url" option update custom_plugin_enabled 1
    else
        echo "Skipping $url (theme: $ACTIVE_THEME)"
    fi
done

echo "Selective deployment completed"

Bulk Content Operations

Perform content operations across multiple sites simultaneously.

# Count posts across all sites
for url in $(wp site list --field=url); do
    COUNT=$(wp --url="$url" post list --post_type=post --format=count)
    echo "$url: $COUNT posts"
done

# Bulk publish scheduled posts
for url in $(wp site list --field=url); do
    SCHEDULED=$(wp --url="$url" post list --post_status=future --format=count)
    if [ "$SCHEDULED" -gt 0 ]; then
        echo "$url has $SCHEDULED scheduled posts"
        wp --url="$url" post list --post_status=future --field=ID | while read -r post_id; do
            wp --url="$url" post update $post_id --post_status=publish
        done
    fi
done

# Delete spam comments network-wide
for url in $(wp site list --field=url); do
    SPAM_COUNT=$(wp --url="$url" comment list --status=spam --format=count)
    if [ "$SPAM_COUNT" -gt 0 ]; then
        echo "Deleting $SPAM_COUNT spam comments from $url"
        wp --url="$url" comment delete $(wp --url="$url" comment list --status=spam --format=ids) --force
    fi
done

Network-wide content creation:

#!/bin/bash
# create-default-pages.sh - Create standard pages on all sites

PAGES=(
    "Privacy Policy|privacy-policy|Privacy policy content here"
    "Terms of Service|terms-of-service|Terms of service content here"
    "Contact|contact|Contact information here"
)

for url in $(wp site list --field=url); do
    echo "Creating pages on: $url"

    for page_data in "${PAGES[@]}"; do
        IFS='|' read -r title slug content <<< "$page_data"

        # Check if page already exists
        if ! wp --url="$url" post list --post_type=page --name="$slug" --format=ids | grep -q '[0-9]'; then
            POST_ID=$(wp --url="$url" post create \
                --post_type=page \
                --post_title="$title" \
                --post_name="$slug" \
                --post_content="$content" \
                --post_status=publish \
                --porcelain)

            echo "Created '$title' (ID: $POST_ID) on $url"
        else
            echo "'$title' already exists on $url"
        fi
    done
done

echo "Page creation completed"

Database Maintenance Across Network

Maintain database health across all sites in the network.

#!/bin/bash
# network-database-maintenance.sh - Database cleanup for all sites

LOG_FILE="/var/log/network-db-maintenance-$(date +%Y%m%d).log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

log "Starting network-wide database maintenance"

for url in $(wp site list --field=url); do
    log "Processing: $url"

    # Get initial database size
    SITE_ID=$(wp site list --url="$url" --field=blog_id)
    INITIAL_SIZE=$(wp db size --tables="wp_${SITE_ID}_*" --format=json 2>/dev/null || echo "0")

    # Delete post revisions older than 90 days
    REVISIONS=$(wp --url="$url" post list --post_type=revision --format=count)
    if [ "$REVISIONS" -gt 0 ]; then
        log "  Deleting $REVISIONS revisions"
        wp --url="$url" post delete $(wp --url="$url" post list --post_type=revision --format=ids) --force
    fi

    # Clean transients
    log "  Cleaning transients"
    wp --url="$url" transient delete --expired

    # Delete orphaned metadata
    log "  Cleaning orphaned metadata"
    wp --url="$url" db query "
        DELETE pm FROM wp_${SITE_ID}_postmeta pm
        LEFT JOIN wp_${SITE_ID}_posts wp ON wp.ID = pm.post_id
        WHERE wp.ID IS NULL
    " 2>/dev/null || true

    # Optimize tables
    log "  Optimizing tables"
    wp db optimize 2>/dev/null || true

    log "  Completed: $url"
done

log "Network database maintenance completed"

Network-Wide Configuration Updates

Apply configuration changes across all sites consistently.

#!/bin/bash
# update-network-config.sh - Apply settings network-wide

# Configuration to apply
CONFIG=(
    "blog_public|0|Disable search engine indexing"
    "default_comment_status|closed|Close comments by default"
    "timezone_string|America/New_York|Set timezone"
    "date_format|F j, Y|Set date format"
    "time_format|g:i a|Set time format"
)

echo "Applying configuration to all sites..."

for url in $(wp site list --field=url); do
    echo "Configuring: $url"

    for config_line in "${CONFIG[@]}"; do
        IFS='|' read -r option value description <<< "$config_line"

        wp --url="$url" option update "$option" "$value"
        echo "  Set $option = $value ($description)"
    done
done

echo "Configuration applied to all sites"

Selective Site Operations

Execute operations on filtered subsets of sites.

#!/bin/bash
# selective-operations.sh - Target specific sites

# Operation 1: Update only sites matching URL pattern
echo "Updating sites with 'blog' in URL..."
for url in $(wp site list --field=url | grep 'blog'); do
    echo "Processing: $url"
    wp --url="$url" plugin activate akismet
done

# Operation 2: Target sites by registration date
echo "Processing recently created sites..."
CUTOFF_DATE=$(date -d "30 days ago" +%Y-%m-%d)

wp site list --format=json | jq -r --arg date "$CUTOFF_DATE" '.[] |
    select(.registered >= $date) | .url' | while read -r url; do
    echo "Recent site: $url"
    wp --url="$url" post create \
        --post_title="Welcome" \
        --post_content="Welcome to our network!" \
        --post_status=publish
done

# Operation 3: Target sites by size
echo "Finding large sites..."
for url in $(wp site list --field=url); do
    POST_COUNT=$(wp --url="$url" post list --format=count)

    if [ "$POST_COUNT" -gt 1000 ]; then
        echo "Large site: $url ($POST_COUNT posts)"
        # Perform operation for large sites
        wp --url="$url" option update posts_per_page 20
    fi
done

User Management Across Network

Manage users and permissions network-wide.

# Add super admin
wp super-admin add username

# Remove super admin
wp super-admin remove username

# List all super admins
wp super-admin list

# Add user to specific sites
USER_ID=123
for url in $(wp site list --field=url | head -5); do
    wp --url="$url" user set-role $USER_ID editor
    echo "Added user $USER_ID as editor on $url"
done

# Find users across all sites
for url in $(wp site list --field=url); do
    USER_COUNT=$(wp --url="$url" user list --format=count)
    echo "$url: $USER_COUNT users"
done

Network-wide user audit:

#!/bin/bash
# audit-network-users.sh - Comprehensive user audit

REPORT_FILE="/var/log/network-user-audit-$(date +%Y%m%d).txt"

echo "=== Network User Audit ===" > "$REPORT_FILE"
echo "Date: $(date)" >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"

# Super admins
echo "Super Administrators:" >> "$REPORT_FILE"
wp super-admin list >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"

# Users per site
echo "Users per site:" >> "$REPORT_FILE"
for url in $(wp site list --field=url); do
    ADMIN_COUNT=$(wp --url="$url" user list --role=administrator --format=count)
    EDITOR_COUNT=$(wp --url="$url" user list --role=editor --format=count)
    TOTAL_COUNT=$(wp --url="$url" user list --format=count)

    echo "$url:" >> "$REPORT_FILE"
    echo "  Admins: $ADMIN_COUNT" >> "$REPORT_FILE"
    echo "  Editors: $EDITOR_COUNT" >> "$REPORT_FILE"
    echo "  Total: $TOTAL_COUNT" >> "$REPORT_FILE"
done

echo "Audit completed: $REPORT_FILE"

Performance Optimization Network-Wide

Optimize performance across all network sites.

#!/bin/bash
# optimize-network.sh - Network-wide performance optimization

echo "=== Network Performance Optimization ==="

TOTAL_SITES=$(wp site list --format=count)
CURRENT=0

for url in $(wp site list --field=url); do
    ((CURRENT++))
    echo "[$CURRENT/$TOTAL_SITES] Optimizing: $url"

    # Clear expired transients
    wp --url="$url" transient delete --expired

    # Clear object cache
    wp --url="$url" cache flush

    # Regenerate missing thumbnails
    MISSING=$(wp --url="$url" media regenerate --only-missing --yes --dry-run 2>&1 | grep -o '[0-9]* images' | grep -o '[0-9]*')

    if [ "$MISSING" -gt 0 ]; then
        echo "  Regenerating $MISSING thumbnails"
        wp --url="$url" media regenerate --only-missing --yes
    fi

    # Update rewrite rules
    wp --url="$url" rewrite flush

    echo "  Completed"
    sleep 1  # Avoid overloading server
done

echo "Network optimization completed"

Monitoring and Reporting

Generate comprehensive reports about your multisite network.

#!/bin/bash
# network-health-report.sh - Generate network health report

REPORT_FILE="/var/reports/network-health-$(date +%Y%m%d).html"

# HTML header
cat > "$REPORT_FILE" << 'EOF'
<!DOCTYPE html>
<html>
<head>
    <title>Network Health Report</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        table { border-collapse: collapse; width: 100%; }
        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
        th { background-color: #4CAF50; color: white; }
        .warning { background-color: #ffeb3b; }
        .error { background-color: #f44336; color: white; }
    </style>
</head>
<body>
    <h1>WordPress Network Health Report</h1>
    <p>Generated: $(date)</p>
EOF

# Network statistics
TOTAL_SITES=$(wp site list --format=count)
ACTIVE_SITES=$(wp site list --archived=false --deleted=false --spam=false --format=count)

cat >> "$REPORT_FILE" << EOF
    <h2>Network Overview</h2>
    <ul>
        <li>Total Sites: $TOTAL_SITES</li>
        <li>Active Sites: $ACTIVE_SITES</li>
        <li>Archived/Deleted/Spam: $((TOTAL_SITES - ACTIVE_SITES))</li>
    </ul>

    <h2>Site Details</h2>
    <table>
        <tr>
            <th>Site URL</th>
            <th>Posts</th>
            <th>Users</th>
            <th>Active Plugins</th>
            <th>Theme</th>
        </tr>
EOF

# Site details
for url in $(wp site list --field=url); do
    POST_COUNT=$(wp --url="$url" post list --post_type=post --format=count)
    USER_COUNT=$(wp --url="$url" user list --format=count)
    PLUGIN_COUNT=$(wp --url="$url" plugin list --status=active --format=count)
    THEME=$(wp --url="$url" theme list --status=active --field=name)

    cat >> "$REPORT_FILE" << EOF
        <tr>
            <td>$url</td>
            <td>$POST_COUNT</td>
            <td>$USER_COUNT</td>
            <td>$PLUGIN_COUNT</td>
            <td>$THEME</td>
        </tr>
EOF
done

# HTML footer
cat >> "$REPORT_FILE" << 'EOF'
    </table>
</body>
</html>
EOF

echo "Report generated: $REPORT_FILE"