Web Development

It is common practice to use local and remote environments when developing for web. In the case of single WordPress sites this works fine. However, Multisite WordPress installations complicate matters significantly.

When searching for a solution to the WordPress Multisite installations problem, we came across a series of articles written by John Russel over at Laubster Boy. In these articles, he marked out a way to solve this problem for remote development and production environments, which was pretty close to what we wanted. With some modification, we managed to get things more or less sorted. However, since then, certain updates to WordPress have broken that solution and we have had to revisit the issue.

In this article, I will show you how we solved this problem using various resources and a bit of tinkering.


We want to be able to host a local installation of the site and a remote installation of the site that both run off the same (remote) database.


WordPress multisite setups always redirect to the base installation, in this case: remote. This makes it difficult to work on a development codebase that will exactly mirror the remote site.

To get around this, it is necessary to run two installations that use different databases. This can lead to confusing conflicts and syncing problems, since with each edit, both the code and the databases have to be merged.


We will be making use of the WPMU Domain Mapping plugin.

Note that a couple of files from this plugin will be edited directly, so it is important to remember this if you update the plugin for any reason!

This process assumes you have a WordPress multisite installation on your remote and local environments.

The following steps for each will be separated by local and remote, since it is important to have different versions of the same files in each location.

So, with the background setup done, let’s get on with the steps!

Directory Structure for Local & Remote WordPress Multisite

For reference, the file structure for this setup, on both local and remote environments, will be as follows:

+-- wp-config.php
+-- .htaccess
+-- wp-content
|   |
|   +-- sunrise.php
|   |
|   +-- plugins
|   	|
|       +-- wordpress-mu-domain-mapping
|     	|
|         +-- Changelog.txt
|     	|
|         +-- domain_mapping.php
|     	|
|         +-- readme.txt
|     	|
|         +-- sunrise.php
|     	|
|         +-- wordpress-mu-domain-mapping.php


1. Remote

This section gives details of remote files and settings.

1.1 wp-config.php

This should not be changed on the remote server.

1.2 WordPress MU Domain Mapping (remote!)

Download the WordPress MU Domain Mapping plugin from here:


Install on remote site and “network activate” so that it is active on all sites.

1.3 sunrise.php

Copy the sunrise.php file from the wordpress-mu-domain-mapping plugin directly into the wp-content directory, so that it is located here:


1.4 End Remote Edits

You are now done with editing the remote site. Visit a few pages to make sure you didn’t break anything, and we’re done here.

2. Local

This section shows what should be done on localhost. Some of the code is duplicated, but all is shown for completeness.

2.1 wp-config.php

Copy the code from below and paste into your wp-config.php file. You can then update it with the details from your remote wp-config.php (database, etc)

You will need to update this with your database details, etc., but we’re going to end up with a document similar to that shown below in “Code Listing 1: wp-config.php“.

Code Listing 1: wp-config.php

define( 'ENVIRONMENT', 'development' );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', true );
define( 'WP_DEBUG', true );
/* MySQL settings */

/** The name of the database for WordPress */
define( 'DB_NAME', 'mysite_db' );
/** MySQL database username */
define( 'DB_USER', 'mysite_user' );
/** MySQL database password */
define( 'DB_PASSWORD', '***********' );
/** MySQL hostname */
define( 'DB_HOST', 'example.com' );
/** Database Charset to use in creating database tables. */
define( 'DB_CHARSET', 'utf8' );
/** The Database Collate type. Don't change this if in doubt. */
define( 'DB_COLLATE', '' );
define( 'AUTH_KEY', '***********' );
define( 'SECURE_AUTH_KEY', '***********' );
define( 'LOGGED_IN_KEY', '***********' );
define( 'NONCE_KEY', '***********' );
define( 'AUTH_SALT', '***********' );
define( 'SECURE_AUTH_SALT', '***********' );
define( 'LOGGED_IN_SALT', '***********' );
define( 'NONCE_SALT', '***********' );
$table_prefix = 'wp_';
/* Multisite */
define( 'WP_ALLOW_MULTISITE', true );
define( 'MULTISITE', true );
define( 'SUBDOMAIN_INSTALL', false );
define( 'DOMAIN_CURRENT_SITE', 'example.com' );
define( 'PATH_CURRENT_SITE', '/' );
define( 'SITE_ID_CURRENT_SITE', 1 );
define( 'BLOG_ID_CURRENT_SITE', 1 );
define( 'ADMIN_COOKIE_PATH', '/' );
define( 'COOKIEPATH', '' );
define( 'SITECOOKIEPATH', '' );
// WPMU shared DB stuff
define( 'SUNRISE', 'on' );
define( 'WP_PRODUCTION_DOMAIN', 'example.com' );
define( 'WP_DEVELOPMENT_DOMAIN', 'mysite-local-domain' );
/* That's all, stop editing! Happy blogging. */
/** Absolute path to the WordPress directory. */
if ( ! defined( 'ABSPATH' ) ) {
   define( 'ABSPATH', dirname( __FILE__ ) . '/' );
/** Sets up WordPress vars and included files. */
require_once( ABSPATH . 'wp-settings.php' );

2.2 WordPress MU Domain Mapping (local!)

Add the same plugin to the local site. You might think that’s that, as per the remote site, but you’d be wrong!

Open up the file domain_mapping.php in your favourite editor and edit lines 708 and 709 so the whole if statement looks like this:

Code Listing 2: domain_mapping.php

if ( $url && $url != untrailingslashit( $protocol . $current_blog->domain . $current_blog->path ) ) {
  $redirect = get_site_option( 'dm_301_redirect' ) ? '301' : '302';
  if ( ( defined( 'VHOST' ) && constant( "VHOST" ) != 'yes' ) || ( defined( 'SUBDOMAIN_INSTALL' ) && constant( 'SUBDOMAIN_INSTALL' ) == false ) ) {
     $_SERVER[ 'REQUEST_URI' ] = str_replace( $current_blog->path, '/', $_SERVER[ 'REQUEST_URI' ] );
//    header( "Location: {$url}{$_SERVER[ 'REQUEST_URI' ]}", true, $redirect );
//    exit;

2.3 sunrise.php

First, copy the sunrise.php file from the wordpress-mu-domain-mapping plugin to the wp-content directory. There might already be a sunrise.php file here. Don’t worry – just overwrite it.

Copy from here:


To here:


Next, edit this new sunrise.php file, adding lines around line 40, so that it looks like the code below:

Code Listing 3: sunrise.php

<?php if ( ! defined( 'SUNRISE_LOADED' ) ) { define( 'SUNRISE_LOADED', 1 ); } if ( defined( 'COOKIE_DOMAIN' ) ) { die( 'The constant "COOKIE_DOMAIN" is defined (probably in wp-config.php). Please remove or comment out that define() line.' ); } $wpdb->dmtable = $wpdb->base_prefix . 'domain_mapping';
$dm_domain     = $_SERVER['HTTP_HOST'];
if ( ( $nowww = preg_replace( '|^www\.|', '', $dm_domain ) ) != $dm_domain ) {
   $where = $wpdb->prepare( 'domain IN (%s,%s)', $dm_domain, $nowww );
} else {
   $where = $wpdb->prepare( 'domain = %s', $dm_domain );
$domain_mapping_id = $wpdb->get_var( "SELECT blog_id FROM {$wpdb->dmtable} WHERE {$where} ORDER BY CHAR_LENGTH(domain) DESC LIMIT 1" );
$wpdb->suppress_errors( false );
if ( $domain_mapping_id ) {
   $current_blog         = $wpdb->get_row( "SELECT * FROM {$wpdb->blogs} WHERE blog_id = '$domain_mapping_id' LIMIT 1" );
   $current_blog->domain = $dm_domain;
   $current_blog->path   = '/';
   $blog_id              = $domain_mapping_id;
   $site_id              = $current_blog->site_id;
   define( 'COOKIE_DOMAIN', $dm_domain );
   $current_site          = $wpdb->get_row( "SELECT * from {$wpdb->site} WHERE id = '{$current_blog->site_id}' LIMIT 0,1" );
   $current_site->blog_id = $wpdb->get_var( "SELECT blog_id FROM {$wpdb->blogs} WHERE domain='{$current_site->domain}' AND path='{$current_site->path}'" );
   if ( function_exists( 'get_site_option' ) ) {
      $current_site->site_name = get_site_option( 'site_name' );
   } elseif ( function_exists( 'get_current_site_name' ) ) {
      $current_site = get_current_site_name( $current_site );
   define( 'DOMAIN_MAPPING', 1 );
// Filters the domain that is displayed/output into HTML
add_filter( 'pre_option_home', 'dev_pre_url_filter', 1 );
add_filter( 'pre_option_siteurl', 'dev_pre_url_filter', 1 );
add_filter( 'the_content', 'dev_content_filter', 100 );
add_filter( 'content_url', 'dev_content_url_filter', 100, 2 );
add_filter( 'post_thumbnail_html', 'dev_content_filter', 100 );
add_filter( 'wp_get_attachment_link', 'dev_content_filter', 100 );
add_filter( 'wp_get_attachment_url', 'dev_content_filter', 100 );
add_filter( 'upload_dir', 'dev_upload_dir_filter', 10 );
function dev_pre_url_filter() {
   global $wpdb, $path, $switched;
   $blog_id = get_current_blog_id();
   if ( ! $switched ) {
      $url = is_ssl() ? 'https://' : 'http://';
      if ( ! is_main_site() ) {
         $url .= rtrim( $path, '/' );
      return $url;
   } else {
      $switched_path = $wpdb->get_var( "SELECT path FROM {$wpdb->blogs} WHERE blog_id = {$blog_id} ORDER BY CHAR_LENGTH(path) DESC LIMIT 1" );
      $url           = is_ssl() ? 'https://' : 'http://';
      $url           .= WP_DEVELOPMENT_DOMAIN;
      $url           .= rtrim( $switched_path, '/' );
      return $url;
function dev_content_filter( $post_content ) {
   global $wpdb;
   $blog_details = get_blog_details();
   $original_url = $wpdb->get_var( "SELECT domain FROM {$wpdb->dmtable} WHERE blog_id = {$blog_details->blog_id} ORDER BY CHAR_LENGTH(domain) DESC LIMIT 1" );
   $dev_url      = WP_DEVELOPMENT_DOMAIN . $blog_details->path;
   if ( $original_url !== null ) {
      $post_content = str_replace( $original_url . '/', $original_url, $post_content );
      $post_content = str_replace( $original_url, $dev_url, $post_content );
   // Change all url's to point to staging (images, anchors, anything within the post content)
   $post_content = str_replace( WP_PRODUCTION_DOMAIN, WP_DEVELOPMENT_DOMAIN, $post_content );
   // Change urls for "uploads" to point to production so images are visible
   $post_content = str_replace( WP_DEVELOPMENT_DOMAIN . $blog_details->path . 'wp-content/uploads', WP_PRODUCTION_DOMAIN . $blog_details->path . 'wp-content/uploads', $post_content );
   return $post_content;
* Filters the content_url function - specifically looking for content_url('upload') calls where path has uploads in the string
* Added so MU-Plugins could use content_url on DEV and PROD
function dev_content_url_filter( $url, $path ) {
   if ( ! empty( $path ) && strpos( $path, 'uploads' ) !== false ) {
      return str_replace( WP_DEVELOPMENT_DOMAIN, WP_PRODUCTION_DOMAIN, $url );
   return $url;
function dev_upload_dir_filter( $param ) {
   $param['url'] 	= str_replace( WP_DEVELOPMENT_DOMAIN, WP_PRODUCTION_DOMAIN, $param['url'] );
   $param['baseurl'] = str_replace( WP_DEVELOPMENT_DOMAIN, WP_PRODUCTION_DOMAIN, $param['baseurl'] );
   return $param;
 * Replacement for /wp-includes/ms-load.php get_site_by_path
function dev_get_site_by_path( $_site, $_domain, $_path, $_segments, $_paths ) {
   global $wpdb, $path;
   // So that there is a possible match in the database, set $_domain to be WP_PRODUCTION_DOMAIN
   // Search for a site matching the domain and first path segment
   $site         = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->blogs WHERE domain = %s and path = %s", $_domain, $_paths[0] ) );
   $current_path = $_paths[0];
   if ( $site === null ) {
      // Specifically for the main blog - if a site is not found then load the main blog
      $site         = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->blogs WHERE domain = %s and path = %s", $_domain, '/' ) );
      $current_path = '/';
   // Set path to match the first segment
   $path = $current_path;
   return $site;
add_filter( 'pre_get_site_by_path', 'dev_get_site_by_path', 1, 5 );

3. .htaccess (local)

Insert the following into your .htaccess file (note that the local directory has been removed from the final index.php RewriteRule line):

Code listing 4: .htaccess

# BEGIN WordPress
<IfModule mod_rewrite.c>;
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
# add a trailing slash to /wp-admin
RewriteRule ^wp-admin$ wp-admin/ [R=301,L]
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^(wp-(content|admin|includes).*) $1 [L]
RewriteRule ^(.*\.php)$ $1 [L]
RewriteRule . /index.php [L]
# END WordPress

4. Virtual Hosts

Set up virtual hosts so that you can type in the value given in wp-config.php (above) for WP_DEVELOPMENT_DOMAIN into the browser address bar and get to the site. Normally you would type localhost, but this will override that.

In the example code (above), the virtual host was set to take “price-buckland” in the address bar and direct to the directory containing the WordPress.

The process for adding virtual hosts in a MAMP environment is as follows:

1) In terminal:

$ sudo vi /etc/hosts


2) Add the following line, replacing as appropriate for the address you want to type into your browser:    mysite-local-domain


3) Next, we want to activate virtual hosts for your local environment, so open the following file for editing:

$ sudo vi /Applications/MAMP/conf/apache/httpd.conf

Find these lines:

# Virtual Hosts
# Include /Applications/MAMP/conf/apache/extra/httpd-vhosts.conf


And uncomment the “Include” line, like so:

# Virtual Hosts
Include /Applications/MAMP/conf/apache/extra/httpd-vhosts.conf


4) Now edit the following file:

$ sudo vi /Applications/MAMP/conf/apache/extra/httpd-vhosts.conf

And add two entries – firstly to allow ‘localhost’ in your browser and, secondly, to add the new virtual host, enabling you to type a custom domain into your browser and reach your local server:

    DocumentRoot "/Users/username/htdocs/"
    ServerName localhost

    DocumentRoot "/Users/username/htdocs/mysite-local-directory"
    ServerName mysite-local-domain

5) Restart apache server. In my case, MAMP, but from the command line run:

apachectl restart

If all went well, you should now have a working setup that lets you code a locally hosted WordPress install, that uses a remote database.

You should also be able to push your changes to the remote server and see an exact copy on the remote site.

Troubleshooting, Caveats and Gotchas!

Unfortunately, many (many) things can go wrong. Here are some symptoms and things to try:

    1. Admin pages are fine, but I can’t visit any internal front-end pages!
    2. .htaccess is either missing or has not been initialised. Add the file and re-save your permalinks.
    3. More coming soon!

When working on your local site, if you go to:

My Sites > Network Admin > Dashboard

It will take you to the remote site, i.e:


This may not be what you want, but you can type directly into the address bar and remain in the local environment, i.e:



If all went well, you should now have a working setup that lets you code a locally hosted WordPress install, that uses a remote database.

You should also be able to push your changes to the remote server and see an exact copy on the remote site.

Let us know how you got on following this step-by-step guide to WordPress multisite for local and remote development in the comments below!


Leave a Reply