Multisite Uploads, Domain Mapping and the Traps Nobody Documents
Cron runs once per site, uploads get a suffix per blog ID, and domain mapping rewrites home and siteurl without moving the upload URL with them.
Martin Kiel · @mkiel
WordPress internals
- Topic
- INFRA
- Section
- Hosting & Infrastructure
- Published
- 16 OCT 2026
- Reading time
- 8 MIN
- Words
- 2050W
A network gets handed over and the list of complaints is short and strange. Images on one site serve from the wrong domain. A scheduled job that should run at midnight fires twice, then stops. A mapped custom domain works for pages and breaks for the media library. None of it is a bug in WordPress, and all of it is documented somewhere, badly.
What follows was produced on a three-site subdirectory network running WordPress 7.0.7 on PHP 8.5.6, with one extra subsite for the mapping test. Where a number depends on the size of the network, I say so rather than dressing up three sites as two hundred.
WP-Cron is per site, and that is a multiplier
The scheduler keeps its state in the cron option. On multisite the option table is
per site, so each site keeps its own array of due hooks and its own set of scheduled events. Nothing
is shared, and nothing deduplicates across the network. Schedule a hook on site one and site two has
never heard of it.
I proved the isolation by making one site the only one that wanted an event. A small must-use plugin clears the hook unless a per-site option says otherwise, so the event exists on exactly one site and nowhere else.
SELECT 'wp_options (site 1)' AS cron_table,
option_value LIKE '%wpsifero_hourly_probe%' AS has_probe
FROM wp_options WHERE option_name = 'cron'
UNION ALL
SELECT 'wp_2_options (site 2)', option_value LIKE '%wpsifero_hourly_probe%'
FROM wp_2_options WHERE option_name = 'cron'
UNION ALL
SELECT 'wp_3_options (site 3)', option_value LIKE '%wpsifero_hourly_probe%'
FROM wp_3_options WHERE option_name = 'cron';
cron_table has_probe
wp_options (site 1) 0
wp_2_options (site 2) 1
wp_3_options (site 3) 0
One scheduled event, one row, one site. The same result falls out of the tables themselves: the
main site's options live in wp_options and subsites get wp_2_options,
wp_3_options, and so on, which is the reason a network's wp_options table
can look healthy and small while the network as a whole is full of scheduled work.
The counts are not identical per site, either. On this three-site network the main site carried five scheduled hooks and twenty timestamps, the second site three hooks and twelve timestamps, and the third two hooks and eight. Each of those is a separate queue with a separate next-run time.
Scale that to a two-hundred-site network and the arithmetic is uncomfortable. A hook registered by a network-activated plugin is registered on every site, so it is scheduled two hundred times and fires two hundred times. A hook registered by a plugin that is only active on forty of the sites is scheduled forty times, and the other one hundred and sixty sites will never run it, which is usually discovered by whoever assumed "network" meant "once".
Two more consequences follow from the isolation, and both are documented in the sense that somebody wrote them down once.
Missed-schedule counts are per site. Each site can accumulate its own backlog of overdue hooks, and a quiet site's backlog grows without anyone noticing because the busy site next door keeps the traffic flowing. The dashboard shows one network. The scheduler shows two hundred independent stores of overdue work.
System cron has to be pointed at the network, not at a site. A single
wp-cron.php call runs the due hooks for the site that the request resolves to. If your
system cron calls the main site's wp-cron.php with a fixed URL, the subsites' schedules
never get a turn.
Uploads get a per-site directory appended, and the option is per site
The upload path on a modern network is not one directory. wp_upload_dir() builds the
base from WP_CONTENT_DIR . '/uploads' and then appends /sites/<blog id>
for any site that is not the main site of the main network. The main site keeps the flat path it had
before the network existed, which is the first asymmetry people trip over.
blog 1: path=wp-content/uploads/2026/10
url=http://localhost:8932/wp-content/uploads/2026/10
blog 2: path=wp-content/uploads/sites/2/2026/10
url=http://localhost:8932/alpha/wp-content/uploads/sites/2/2026/10
blog 3: path=wp-content/uploads/sites/3/2026/10
url=http://localhost:8932/beta/wp-content/uploads/sites/3/2026/10
The suffix is appended to whatever the site's upload_path option says, not instead of
it. Set that option to a custom location on one site and the result is the custom path with
/sites/3 after it.
beta path=wp-content/media/beta/sites/3/2026/10
beta url=http://localhost:8932/beta/wp-content/media/beta/sites/3/2026/10
And the option is genuinely per site. I set upload_path on site three and read it
back on site two as an empty string. That is the good news. The bad news is the other direction: a
migration script that sets upload_path on the main site changes where the main site
writes files, while every subsite keeps writing to sites/N underneath the old location.
Nothing warns you. Files land in a directory that the media URLs for the main site no longer point
at, and the only visible symptom is a broken image on one page.
Legacy uploads are a different layout
Networks created before WordPress 3.5 had ms_files_rewriting enabled and used
wp-content/blogs.dir with a /files URL. ms_upload_constants()
only defines UPLOADS and BLOGUPLOADDIR when that site option is on, and
wp_upload_dir() honours UPLOADS only while it is off. On the network I
built, ms_files_rewriting was empty, so none of that code path ran. Check the
wp_sitemeta row before you plan a migration around it.
Domain mapping rewrites two options, and WordPress keeps the rows
Mapping a custom domain onto a subsite is a rewrite of home and
siteurl, because almost everything WordPress generates as a URL is built from one of
those two options. The supported place to do it is sunrise.php, a drop-in that
ms-settings.php loads before WordPress has decided which site the request belongs to.
Enable it in wp-config.php and put the file in wp-content.
// wp-config.php
define( 'SUNRISE', true );
// wp-content/sunrise.php
$map = array( 'shop.example.net' => 2 );
$host = strtolower( strtok( (string) ( $_SERVER['HTTP_HOST'] ?? '' ), ':' ) );
if ( isset( $map[ $host ] ) ) {
$current_site = new stdClass();
$current_site->id = 1;
$current_site->domain = $host;
$current_site->path = '/';
$current_blog = new stdClass();
$current_blog->domain = $host;
$current_blog->path = '/';
$blog_id = $map[ $host ];
}
Two things in that file are not decoration. Setting $blog_id is what selects the
site. Building $current_site and $current_blog yourself is needed because
those globals do not exist yet at this point in the load; assigning a property on an unset variable
is a fatal error in modern PHP, and the stack trace points at a line of your drop-in rather than at
the mistake. A production mapper reads its map from the database and fails back to the network
domain when the host is unknown, but the shape is this.
The rewrite works, and the database does not move. On the mapped site, home and
siteurl both read as the mapped domain, and the raw rows in
wp_2_options still hold the network URL.
blog 2: home=https://shop.example.net siteurl=https://shop.example.net
blog 3: siteurl=http://localhost:8932/beta blogname=Site beta
mysql> SELECT option_name, option_value FROM wp_2_options
WHERE option_name IN ('home','siteurl');
home http://localhost:8932/alpha
siteurl http://localhost:8932/alpha
That separation is the feature. Deactivating the mapper restores the network URLs with no database work, and a site can be visited through its network URL while the mapping is live. It is also the source of the confusion, because two different people looking at "what is this site's URL" can both be right.
The upload URL does not follow the mapped domain
This is the trap that produces the "images are broken on the mapped domain" ticket, and it is not
obvious unless you know when one constant is defined. WP_CONTENT_URL is derived from
siteurl during boot, before plugins and before any option filter a mapper could attach.
wp_upload_dir() builds its URL from WP_CONTENT_URL, so it keeps serving the
network domain even when home and siteurl have been rewritten.
$ wp --url=http://shop.example.net/ eval '...'
blog_id=2 home=https://shop.example.net siteurl=https://shop.example.net
WP_CONTENT_URL=http://localhost:8932/wp-content
uploads baseurl=http://localhost:8932/wp-content/uploads/sites/2
The page HTML is generated with the mapped domain and the image URLs are generated with the
network domain. On a network where both hostnames resolve to the same WordPress install, nothing
looks broken and nobody notices: the browser fetches localhost:8932, the server answers,
the image renders. The problem starts when the two hostnames are served by different front ends, or
when one of them does not resolve from the outside at all.
The fix belongs in the mapper, and it is a filter rather than a constant. Either set
upload_url_path per site to the mapped URL, or filter the upload directory's
baseurl and url so they use the mapped host. Do not try to define
WP_CONTENT_URL from sunrise.php unless you are prepared to own every
consequence of that decision for the rest of the network's life.
What the mapping approach does not solve is cookies. Set the mapped domain as the site's canonical host and the login cookies are issued for it, so a user can be logged in at the mapped domain and logged out at the network URL. That is normal and correct, and it is also why an administrator testing from the network URL reports bugs that only exist in the session they created.
Counting the schedules is a cross-table query, not a loop
Nothing in the admin shows you the network's scheduled work, and there is no single query that counts it either, because the cron rows are spread across one options table per site. The shortcut that works is to ask the information schema how many of those tables exist and hold a cron row. On the three-site network I built, the answer is three, and the network has three sites. That is not a coincidence, it is the structure.
SELECT COUNT(*) AS sites_holding_a_cron_option
FROM information_schema.tables t
WHERE t.table_schema = 'm_art_ms'
AND t.table_name LIKE '%\_options'
AND EXISTS (
SELECT 1 FROM wp_options o WHERE o.option_name = 'cron'
);
The subquery is hardcoded to wp_options, which makes the count wrong on a network
where the main site has no cron row, so treat it as the shape of the query rather than a production
script. The honest version loops the table names and counts a cron row in each. Either way, the
number you get is the number of schedules, and it is the number to put in the handover document next
to the site count.
A hook that fires on every one of them is also worth pricing before it ships. Two hundred
schedules of a five-second job is a thousand seconds of work per interval, spread across whichever
requests happen to be first, which on a quiet network means a visitor's page load. That is the
practical cost of the per-site design, and it is the reason a network-wide job belongs in a single
system cron task rather than in wp_schedule_event().
A cache inside a cache inside a request
One more upload detail that costs people time. wp_upload_dir() keeps a static array
keyed by blog ID for the life of the request, so a script that loops over sites and switches between
them does get the right answer per site, and repeated calls inside the same request do not re-run
the path logic. The cache is not cleared by switch_to_blog(), and it is not cleared by
restore_current_blog() either.
The consequence is about ordering. A function that calls wp_upload_dir() before its
own upload_dir filter is attached caches the unfiltered result, and every later call in
that request gets the unfiltered result back. The symptom is an upload that lands in the default
directory on exactly one code path, on exactly one site, and nowhere else. When a site reports that,
stop reading the upload code and start looking for an early wp_upload_dir() call. The
$refresh_cache argument exists for exactly this, and passing
true as the third argument forces the recomputation.
Write the checks down before the handover
Most of this is knowable in advance with three commands. List the sites and their registered URLs.
Dump the count of scheduled hooks per site. Print wp_upload_dir() for the main site and
for one subsite, and compare the two base URLs. Anything that differs between them is something the
next person will spend an afternoon on.
The limit of all this is that nothing here helps a network that has already diverged, where some
site's upload_path was changed in 2021 and the files still live in the old directory.
Documented traps are cheaper to avoid than to find. Find them before the handover, not after the
first support ticket at midnight.
Martin Kiel · @mkiel
WordPress internals
Writes about the load-bearing parts of WordPress: the options table, hook ordering, WP_Query behaviour and the database work that decides whether a plugin scales or falls over.
Continue