I've been running a personal WordPress multisite instance for several years now, and I use it to host a variety of personal and organizational sites, including this one. I really like the ways it allows me to standardize and consolidate my management of WordPress as a tool, while still allowing a lot of flexibility for customizing my sites just as though they were individual self-hosted sites.

For the most part, my use of WordPress in multisite/network mode doesn't have any user-facing implications, especially since I use the WordPress MU Domain Mapping plugin to map custom domain names to every site I launch. As far as anyone visiting my sites knows, it's a standalone WordPress site that looks and works like any other.

The one exception to this has been the URL structure for images and other attachments that I upload to any site hosted on this multisite instance. Whereas the typical WordPress image URL might look like this:

https://example.com/wp-content/uploads/2019/03/my_image.jpg

on a multisite instance, there is an directory structure added in to separate each site's uploads into its own subdirectory:

https://example.com/wp-content/uploads/sites/25/2019/03/my_image.jpg

where 25 might be the site's unique site ID within that multisite setup.

There's nothing wrong with this approach and it certainly makes technical sense if you have lots of sites on your multisite instance that are either subdirectories or subdomains of the main multisite domain.

But I don't like it because it means my individual sites start to have their post content and other fields (options, post meta) "polluted" with attachment URLs that are specific to this particular hosting environment; the individual site ID and the use of multisite at all.

One of the great benefits of WordPress is that you can move a given site between hosting environments and, assuming you're using best practices to start with, assume that everything will still work as expected and point to the right place. Yes, there are some relatively easy ways to clean this up after a hypothetical move to another, non-multisite environment, but I prefer solutions that make the day-to-day experience more standard instead of requiring exceptional workarounds or cleanup.

Fortunately in the case of using mapped domains, we have another way to help WordPress identify which subdirectory in the uploads directory structure we're trying to fetch an image from, and so it provides an opportunity to simplify and standardize the attachment URL structure.

(Please beware any solution to this issue that involves changing the actual upload directory destination, modifies WordPress core files, or anything else that might break the underlying functionality of your sites now or after future upgrades.)

There are two parts to this:

  1. Make your webserver (in my case, nginx) serve images from both the multisite attachment URL structure and the non-multisite URL structure, in the case of a mapped domain.
  2. Make WordPress use the simplified, non-multisite URL structure anywhere it can.

Serving attachments from both URL structures

To tell nginx to use both URL structures in the case of an attachment URL on a site with a mapped domain, there are a few steps.

First, create an nginx configuration map that lets nginx know about the relationship between a mapped domain name and the internal numeric ID that WordPress uses for each site in a multisite instance. The easiest way to do this in my experience is with the Nginx Helper plugin from the folks at rtCamp. One of its features is to automatically generate and update an nginx-friendly mapping every time you add or remove a site from your multisite instance. Just check the "Enable Nginx Map" checkbox and you should find a new file available in wp-content/uploads/nginx-helper/map.conf.

Now, you need to tell nginx to use this map file in its configuration:

map $http_host $blogid {
    default       -999;
    include /path/to/wordpress/wp-content/uploads/nginx-helper/map.conf ;
}

In the context of a single site that nginx is serving, this will now set the $blogid nginx variable with the internal ID that WordPress uses to identify that site. Using that information, we can tell nginx to try serving a non-multisite version of an attachment URL:

location ~ ^/wp-content/uploads/(\d+.*)$ {
    try_files $uri /wp-content/uploads/sites/$blogid/$1 ;
    expires max;
}

In other words, if I'm on $blogid 25 and https://example.com/wp-content/uploads/2019/03/my_image.jpg doesn't already exist naturally, then try to find it in the directory and file location /wp-content/uploads/sites/25/2019/03/my_image.jpg instead.

Making this change doesn't hurt anything and won't change how your sites operate, but sets us up for the next step where we modify how WordPress behaves.

Tell WordPress to use the simpler URL structure

Now we need to tell WordPress that on sites with mapped domains, it can use the simpler URL structure.

I do this using a "must-use" plugin on my multisite setup that does two main things: rewrite the URL to use the mapped domain instead of the multisite subdomain, and rewrite the URL further to remove the reference to /sites/25/ (or whatever the number might be).

Here's my plugin code, also available as a gist for easier downloading:

<?php
/**
 * Plugin Name: Multisite Domain Mapping Attachment URL Fixes
 * Description: Update attachment URLs to use mapped domain and remove mention of "sites" path.
 * Author: Chris Hardie
 *
 * Rewrite attachment URLs (and related srcset URLs) to the non-multisite, mapped domain version if a domain is mapped
 * Requires that the related nginx config that maps the non-multisite URL to the multisite URL be in place
**/
add_filter( 'wp_get_attachment_url', 'jch_attachment_url_with_domain_mapping' );

function jch_attachment_url_with_domain_mapping( $attachment_url ) {

    global $wpdb;

    // Comes from https://wordpress.org/plugins/wordpress-mu-domain-mapping/
    if ( function_exists( 'get_original_url' ) && function_exists( 'domain_mapping_siteurl' ) ) {
        $orig_url = get_original_url( 'siteurl' );

        $mapped_url = domain_mapping_siteurl( 'NA' );

        if ( 'NA' !== $mapped_url ) {
            if ( ! empty( $orig_url ) && ( $orig_url !== $mapped_url ) ) {
                $attachment_url = str_replace( $orig_url, $mapped_url, $attachment_url );
            }
            $attachment_url = preg_replace( '!wp-content/uploads/sites/\d+/!', 'wp-content/uploads/', $attachment_url );
        }
    }

    return $attachment_url;

}

add_filter( 'wp_calculate_image_srcset', 'jch_srscet_urls_with_domain_mapping' );

function jch_srscet_urls_with_domain_mapping( $sources ) {

    if ( function_exists( 'domain_mapping_siteurl' ) ) {
        $domain_mapped_url = domain_mapping_siteurl( 'NA' );

        if ( 'NA' !== $domain_mapped_url ) {
            foreach ( $sources as $source ) {
                if ( ! empty( $source['url'] ) && ! empty( $source['value'] ) ) {
                    $sources[ $source[ 'value' ] ][ 'url' ] = preg_replace( '!wp-content/uploads/sites/\d+/!', 'wp-content/uploads/', $sources[ $source[ 'value' ] ][ 'url' ] );
                }
            }
        }
    }

    return $sources;
}

I installed that plugin in a file in my wp-content/mu-plugins directory, and I'm done.

Here are some examples of the places where this plugin takes effect:

  • When uploading an image to the media library, the URL that's displayed in the attachment edit screen.
  • When authoring a post or page and inserting media, the image source URL that's inserted.
  • When WordPress generates srcset values to facilitate responsive images, in the image URLs used.

And so on.

Again, because we're not changing the behavior of the internal WordPress file uploading functionality or filesystem structure, nothing about the core attachment management is any different; just the way WordPress generates the attachment URLs that the rest of the world sees.

Cleaning up old URL references

If you have a perfectionist streak like me, you might now also want to go back and update the attachment URLs that use the multisite version so they use the non-multisite version. Here are the WP CLI commands I used to do this for a single site:

$ wp --url=https://example.com search-replace 'sites/25/' '' --dry-run --recurse-objects --skip-columns=guid --report-changed-only
$ wp --url=https://example.com search-replace 'sites/25/' '' --recurse-objects --skip-columns=guid --report-changed-only
$ wp --url=https://example.com cache flush

After running the first "dry run" command you may want to check the results to make sure you understand the implications of doing this find/replace for real. And PLEASE make a backup first.

If you have a large network of sites, you may want to automate this for all of your sites.

Once this cleanup is done, it could also make sense to use nginx to redirect all requests to the multisite attachment URL structure to the non-multisite version, when a mapped domain is present.

 

I hope this information is helpful for anyone wanting to fine-tune their WordPress multisite setup. I'd love to hear your tips and comments.

2 thoughts on “Better WordPress multisite image URLs

  1. Thanks for this tip.

    WordPress upgrades can already complex when making sure all plugin versions are compatible with the new version.

    With Multisite WordPress the complexity is compounded by coordinating the upgrade of a single WordPress instance with *multiple* sets of plugins specific to different WordPress sites.

    For someone who has just a couple WordPress sites, do you think it's worth using Multisite WordPress? Do you have other tips for coordinating upgrades of Multisite WordPress installs?

    Thanks.

    1. I think I started to see economies of scale at around the 3-site threshold for coordinating upgrades across all of them. But I also try to using as few plugins as possible, so YMMV depending on the complexity of each site and how well-written each plugin is.

      I think the upgrade tips are probably the same for Multisite as they are for standalone sites. For minor point releases I usually just upgrade and things are fine. For major version releases: read the Changelog, good backups, test in a dev environment first (doesn't have to be Multisite unless the site is doing something special), have automated tests, watch the logs for warnings and errors.

      For upgrading plugins themselves, I tend to wait at least 3 business days since a plugin version is released to make sure there aren't any severe bugs discovered by others, longer if the plugin is less widely used. I also read the changelogs of each one and look at diffs if I'm concerned any "features" they're introducing.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.