You've installed WordPress Multisite. Network activated a few plugins. Maybe even mapped a custom domain or two. But your network bogs down at scale, subsites load inconsistently, and you're staring at database queries that take seconds instead of milliseconds. The default Multisite setup is fine for small networks, but production environments need deeper tuning.
This guide skips the "add these two lines to wp-config.php" basics and focuses on what actually matters when you're running dozens or hundreds of subsites: object caching layers, database table optimization, asset delivery, domain mapping at the web-server level, and the edge cases that bite you at 3 AM.
Object caching is non-negotiable
WordPress Multisite without persistent object caching is a database time bomb. Every subsite loads its own options, user metadata, and transients from shared tables. Multiply that by fifty active subsites and your wp_options and wp_usermeta queries will crush MySQL.
Redis or Memcached both work. I've seen Redis handle more traffic with less memory in hosting environments, but Memcached is simpler if you already have it running. Install the PHP extension first:
# For Redis
sudo apt install php-redis redis-server
sudo systemctl enable --now redis-server
# For Memcached
sudo apt install php-memcached memcached
sudo systemctl enable --now memcached
Drop an object-cache.php drop-in into wp-content/. Don't rely on plugins that "add caching support" — use a dedicated drop-in like the ones from Till Krüss for Redis or the official Memcached implementation. Set WP_CACHE_KEY_SALT in wp-config.php to something unique to this network so you don't collide with other WordPress installs on the same Redis instance:
define('WP_CACHE_KEY_SALT', 'mynetwork_prod');
Flush the object cache after any plugin or theme update that touches options. I've debugged "ghost settings" in Multisite more times than I can count, and stale cache entries are usually the culprit.
Database table indexes you probably don't have
The wp_blogs and wp_site tables are small, but wp_options and wp_sitemeta grow fast. By default, WordPress creates a primary key on option_id and a unique key on option_name for wp_options, but Multisite queries often filter by blog_id in joined queries.
Add a composite index on high-traffic tables:
ALTER TABLE wp_options ADD INDEX blog_autoload (blog_id, autoload);
ALTER TABLE wp_postmeta ADD INDEX post_meta_blog (meta_key(191), blog_id);
The meta_key(191) prefix length keeps the index size reasonable on utf8mb4 columns. Run EXPLAIN on your slow query log to confirm what's actually being scanned. If you see "Using filesort" or "Using temporary" on queries that touch wp_options, you're missing an index.
Partition wp_options by blog_id if your network has hundreds of subsites and you're comfortable with table partitioning. It's overkill for most setups, but when a single subsite's options table hits millions of rows, partitioning cuts query time from seconds to milliseconds.
Domain mapping at the Nginx or Apache level
WordPress plugins that handle domain mapping (Mercator, the old WordPress MU Domain Mapping) add PHP overhead to every request. They work, but they're slow. Map domains in your web server configuration instead.
In Nginx, use a map block to route custom domains to the correct subsite:
map $http_host $blogid {
default 1;
client1.com 5;
client2.com 12;
client3.com 18;
}
server {
listen 80;
server_name network.example.com client1.com client2.com client3.com;
root /var/www/html;
index index.php;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param BLOG_ID_CURRENT_SITE $blogid;
include fastcgi_params;
}
}
The BLOG_ID_CURRENT_SITE FastCGI param tells WordPress which subsite to load without a database lookup. You still need a lightweight sunrise.php to set $current_blog, but the heavy lifting happens in Nginx.
Apache uses mod_rewrite and environment variables:
<VirtualHost *:80>
ServerName network.example.com
ServerAlias client1.com client2.com client3.com
DocumentRoot /var/www/html
RewriteEngine On
RewriteCond %{HTTP_HOST} ^client1\.com$ [NC]
RewriteRule ^ - [E=BLOG_ID:5]
RewriteCond %{HTTP_HOST} ^client2\.com$ [NC]
RewriteRule ^ - [E=BLOG_ID:12]
RewriteCond %{HTTP_HOST} ^client3\.com$ [NC]
RewriteRule ^ - [E=BLOG_ID:18]
SetEnvIf Request_URI . BLOG_ID_CURRENT_SITE=%{ENV:BLOG_ID}
</VirtualHost>
Keep a spreadsheet or config management system that maps domain → blog_id. When a client changes domains, you update the web server config and reload, not hunt through WordPress admin.
CDN integration and asset domains
Multisite networks serve the same core theme and plugin assets to every subsite. Those shouldn't hit your origin server. Push wp-content/themes, wp-content/plugins, and wp-includes to a CDN with a long cache TTL.
Cloudflare works, but if you're already using object storage (S3, Backblaze B2, Wasabi), serve static assets directly from there. Install the WP Offload Media plugin or S3 Uploads, configure it once at the network level, and every subsite's uploads go straight to object storage.
Set a separate cookie-free asset domain for static files:
define('WP_CONTENT_URL', 'https://assets.example.com/wp-content');
define('WP_PLUGIN_URL', 'https://assets.example.com/wp-content/plugins');
Your main network domain still serves HTML and dynamic content. Assets load from a different origin, so browsers open more parallel connections and cookies don't inflate every request.
Subsite provisioning and template automation
Manually configuring every new subsite doesn't scale. The network admin "Add New Site" screen is fine for two or three subsites, but when you're spinning up client sites daily, you need automation.
Use WP-CLI to script subsite creation:
wp site create \
--slug=client-name \
--title="Client Name" \
[email protected] \
--network_id=1 \
--private
# Activate a template theme
wp theme enable base-theme --network
wp --url=client-name.network.example.com theme activate base-theme
# Import a JSON settings file
wp --url=client-name.network.example.com option update blogname "Client Name"
wp --url=client-name.network.example.com option update timezone_string "America/New_York"
Combine those commands in a shell script or invoke them from a provisioning API. Store subsite configuration as JSON or YAML so new sites inherit the same plugins, theme settings, and user roles.
Network-activate a "new site defaults" plugin that runs on wpmu_new_blog. Set homepage layout, privacy settings, permalink structure, and any other options that should be identical across subsites. If a client wants custom configuration, override it after provisioning, but start from a known-good baseline.
Filesystem permissions and security boundaries
Multisite shares a single codebase. One compromised subsite can drop malicious PHP into shared wp-content/uploads or wp-content/themes. Separate upload directories by blog_id (WordPress does this by default), but also isolate them at the filesystem level.
Run PHP-FPM with per-subsite Unix users if your hosting platform supports it. That's rare on shared hosting, but on a VPS or dedicated server, you can configure PHP-FPM pools:
[client1]
user = client1
group = client1
listen = /run/php/client1.sock
[client2]
user = client2
group = client2
listen = /run/php/client2.sock
Point Nginx or Apache to the correct socket based on domain. Now if client1.com gets compromised, the attacker runs as the client1 user and can't write to client2's files.
Disable the plugin and theme file editors network-wide:
define('DISALLOW_FILE_EDIT', true);
define('DISALLOW_FILE_MODS', true);
DISALLOW_FILE_MODS also blocks plugin and theme installation from the admin UI. Deploy code changes through version control and CI/CD instead.
Handling plugin and theme updates at scale
Updating a plugin on a fifty-subsite network can take minutes if WordPress has to flush rewrite rules and regenerate cache for each site. Some plugins run expensive activation hooks on every subsite when network-activated.
Test updates in staging first. Clone your production database, run the update, and check error logs. I've seen "minor" plugin updates break admin screens on subsites because of an undeclared dependency or a change in hook priority.
Disable automatic updates for plugins and themes:
add_filter('auto_update_plugin', '__return_false');
add_filter('auto_update_theme', '__return_false');
Schedule updates during low-traffic windows and monitor CPU and memory usage. If a plugin update triggers a background process (like reindexing search), it might spawn that process for every subsite simultaneously. Rate-limit background jobs or run them serially with WP-CLI:
for site in $(wp site list --field=url); do
wp --url="$site" cron event run --due-now
sleep 2
done
What to check when performance degrades
Multisite performance problems show up gradually. First check object cache hit rates — if it's below seventy percent, your cache TTLs are too short or your cache backend is evicting keys. Next, run SHOW PROCESSLIST in MySQL and look for queries stuck in "Sending data" or "Copying to tmp table." Those are almost always missing indexes or unoptimized joins.
Profile with Query Monitor on a staging subsite. It'll show you which plugins are hammering the database and which hooks are firing hundreds of times per page load. In my support tickets, the most common culprit is a poorly coded plugin that runs get_option() in a loop without caching.
Finally, check your PHP opcache settings. Multisite loads more PHP files than a standard install, so the default opcache memory limit is often too small. Bump opcache.memory_consumption to at least 256M and set opcache.max_accelerated_files above the number of PHP files in your WordPress directory.
