Get Off My Lawn!

...and Out of My Dashboard

Scaling WordPress horizontally on IIS

by Dougal Campbell

This presentation:

Photo credit: Pascal Maramis, Flickr, CC BY 2.0, https://flic.kr/p/f6VuiF

Goals: System

  • Multiple WordPress front-ends, fronted by a load balancer
  • Shared wp-content directory
    • Avoid sync issues with media uploads
    • Plugins and themes stay in sync, as well
  • Separate, shared MySQL server
    • Separate, to spread load
    • Shared, to keep configuration consistent
  • Responsive, mobile-friendly theme

Goals: Social

  • Limit capabilities of individual site Webmasters
  • Allow extra capabilities for "trusted" Webmasters

Challenges

  • WIMP stack: Windows platform with IIS web service
  • Users who are clever
  • ...but not in ways you want them to be

Note:

Same Principals for Linux

Though this project was using Windows servers, these same basic techniques can be used for scaling Linux servers.

Three Servers

  1. WP-Database
  2. WP-Web1
  3. WP-Web2

There could be additional front-end WordPress servers. Their setup would be identical to WP-Web1 and WP-Web2.

Four Servers

Actually, there is a fourth server -- a development server. It's a stand-alone server where we can test and experiment without worrying about accidently taking down the live, production site.

  • WordPress Core upgrades
  • Plugin / Theme upgrades
  • New plugins
  • Design changes

Server Setup

Warning: Tech Ahead!

Web Platform Installer

https://www.microsoft.com/web/downloads/platform.aspx

Microsoft's WPI is probably the easiest way to get WordPress up and running with IIS.

It will install MySQL, PHP (as a FastCGI process), and WordPress. It will also create the IIS site configuration, the MySQL user and database for WordPress to use, and the initial settings for your wp-config.php file.

We use WPI to install WordPress to all three of our servers.

Domain User

Because we want to use a shared content directory, we need to create a domain user which IIS/PHP will use for shared filesystem permissions. Let's call it MYDOMAIN\IISPHP.

Keep in mind the security principal of Least Privilege. This account should only have the access it absolutely needs.

WP-Database

This server is not in the load-balancing rotation, and is only accessible to the front-end servers and the internal network. However, we still want WordPress running here, because it is going to hold the master copy of the wp-content folder which will be shared on the network to the front-end servers.

We give our MYDOMAIN\IISPHP account ownership and control of wp-content.

WP-Database

After installing WordPress, we configured Multisite in the usual way, by setting the appropriate define() statements in the wp-config.php file.


/* Multisite */
define( 'WP_ALLOW_MULTISITE', true );
define( 'MULTISITE', true );
define( 'SUBDOMAIN_INSTALL', false );
define( 'DOMAIN_CURRENT_SITE', 'cherokeek12.net' );
define( 'PATH_CURRENT_SITE', '/' );
define( 'SITE_ID_CURRENT_SITE', 1 );
define( 'BLOG_ID_CURRENT_SITE', 1 );
						

WP-Database

By default, the DB_USER account can only connect to MySQL from its own server. We have to tell MySQL to allow the user to connect from our other front-end servers. Run mysql from the command line, and grant permissions to the user:

C:\> mysql
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 86197

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> GRANT ALL PRIVILEGES ON wpdbname.* TO 'wpdbuser'@'server.ip.number';

Do this for each front-end server (WP-Web1 & WP-Web2), substituting the appropriate values for the database name, user name, and server IP address.

If you aren't comfortable with the command line, you can also do this with the MySQL Workbench app, under Users and Privileges.

WP-Web1 & WP-Web2

These are our load-balanced front-ends. We can disable the MySQL service on these servers, and modify wp-config.php to point to the WP-Database server. They should use the same database settings as WP-DATABASE. For example:

define('DB_NAME', 'wordpress999');
define('DB_USER', 'wordpressuser999');
define('DB_PASSWORD', 'mydbpassword');
define('DB_HOST', '10.1.2.3');

WP-Web1 & WP-Web2

We also delete (or rename, or move) the wp-content folder on these servers, and replace it with a link to the network share from WP-Database:

mklink /d C:\InetPub\wwwroot\wp-content \\WP-Web1\wwwroot\wp-content

This lets WordPress map URLs <==> filenames without additional trickery.

WP-Web1 & WP-Web2

Challenge: Media Uploads

When we reached this point in our server configuration, we discovered that WordPress media uploads did not work on the front-end servers. We spent many hours checking file permissions, Googling, re-checking file permissions, Googling some more, etc.

Over time, we discovered that there were several pieces to this puzzle, and you have to get all of them into place before uploads will work in our shared content directory configuration.

WP-Web1 & WP-Web2

IIS "Connect As" User

In IIS Manager, go to mywebsite.net -> Basic Settings -> Connect As, and set it to use your MYDOMAIN\IISPHP user account.

Then go to Application Pools, select your Application Pool, and click Advanced Settings. Set the Identity to ApplicationPoolIdentity.

This will make IIS/PHP identify itself as the domain user to which we have given permissions on the shared wp-content folder.

WP-Web1 & WP-Web2

PHP upload_tmp_dir

Add new defines in wp-config.php:

/* Use shared WP-CONTENT directory */
define( 'WP_CONTENT_DIR', '\\\\WP-Database\\inetpub\\wwwroot\\wp-content' );
define( 'WP_TEMP_DIR', '\\\\WP-Database\\inetpub\\wwwroot\\wp-content\\phptemp' );

In php.ini:

upload_tmp_dir = \\WP-Database\inetpub\wwwroot\wp-content\phptemp

Be sure to create the wp-content/phptemp directory, and set ownership/permissions for your MYDOMAIN\IISPHP user.

By using UNC paths for these three settings, and putting the temp upload directory within our shared folder, the move_uploaded_file() call in WordPress Core is able to succeed, because Windows sees that all the paths are in the same filesystem.

sh0w m3 teh c0deZ!

At this point, we had a mini server farm with distributed load. We could scale things further by adding more front-end servers. We could also scale the database with a Master/Slave configuration, with all database writes directed to the Master, and reads distributed between multiple Slaves.

But really, we just want to finally play with our sites!

Site-Specific Styles

In Susan's use-case, every school in the district has its own website. They are all using the same theme (her custom child theme, with Enigma as the parent theme), but they have different color schemes.

We made a subdirectory named stylesheets under the child theme directory. In there, we can place our site-specific stylesheets with a name scheme of sitenamestyle.css.

For example, for Creekview High School, the site name is creekviewhs, and so we have a site-specific stylesheet called creekviewhsstyle.css in the wp-content/themes/ccsd/stylesheets directory.

Site-Specific Styles

The Code


/**
 * Load parent styles and child theme overrides.
 */
function ccsd_enqueue_styles() {
    $parent_style = 'enigma-theme';
    $child_style = '/ccsdstyle.css'; // default

    // Override per-site
    if ( function_exists( 'get_blog_details' ) ) {
        $details = get_blog_details();
        $path = $details->path;    /* e.g.: "/mysitename/" */
        $newpath = "/stylesheets" . preg_replace('|/$|', 'style.css', $path);
        if ( file_exists( get_stylesheet_directory() . $newpath ) ) {
            $child_style = $newpath;
        }
    }

    wp_enqueue_style( $parent_style, get_template_directory_uri() . '/style.css' );
    wp_enqueue_style( 'child-style',
        get_stylesheet_directory_uri() . $child_style,
        array( $parent_style,"bootstrap", "default", "font-awesome", "media-responsive" )
    );
}

add_action( 'wp_enqueue_scripts', 'ccsd_enqueue_styles' );
						

Footer Menu Layout

Every site has a common menu of over 30 help links in the footer. Internally, this is managed as a Nav Menu, which WordPress will render as a single unordered list, by default. Susan wanted these links to be distributed in three columns. We took an easy route -- since the Enigma theme uses Boostrap CSS, there are built-in CSS classes for columns. We can get our columns by simply adding these classes to each <li> with a filter.

Footer Menu Layout

The Code


/**
 * Add Bootstrap column classes to nav menu items, only for items
 * in the 'secondary' (footer) nav menu.
 */
function ccsd_nav_menu_css_class($classes, $item, $args, $depth = 1) {
    if ( isset( $args->theme_location ) && 'secondary' == $args->theme_location ) {
        $classes[] = 'col-md-4';
        $classes[] = ' col-sm-6';
    }

    return $classes;
}

add_filter('nav_menu_css_class', 'ccsd_nav_menu_css_class', 10, 4);
						

Footer Menu Layout

The Result

Image of nav menu rendered in columns by using Bootstrap classes

Dashboard Restriction

Oh, Those Troublesome Users

Certain items on the subsites needed to be consistent, and not edited by the individual school webmasters. These included Widgets and many of the theme options. Default WordPress Roles and Capabilities do not give fine-grained control over these things -- you either have the theme-options capability, or you don't.

Our first pass at limiting webmaster site control was to simply remove the Dashboard links for those items.

Removing menu options for Widgets and Themes


/**
 * Restrict dashboard access to only allow users to change Menus and 
 * Theme Options / Theme Slider Options. Access to other theme options
 * requires the user to have the 'edit_themes' capability.
 */
function ccsd_hide_menus() {
    global $wp_customize; // Customizer Manager

    /**
     * Must have at least 'edit_themes' capability to see all theme panels:
     */
    if ( current_user_can( 'edit_theme_options' ) && ! current_user_can( 'edit_themes' ) ) {
        // Remove 'Themes', 'Widgets', and 'About Enigma' menu entries:
        remove_submenu_page('themes.php', 'themes.php');
        remove_submenu_page('themes.php', 'widgets.php');
        remove_submenu_page('themes.php', 'enigma');

        // Remove 'Background' menu entry:
        $customize_url = add_query_arg( 'return', urlencode( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 'customize.php' );
        $customize_background_url = esc_url( add_query_arg( array( 'autofocus' => array( 'control' => 'background_image' ) ), $customize_url ) );
        remove_submenu_page('themes.php', $customize_background_url );

        /*
         * Remove all Customizer menu entries except 'Menus' and 
         * the Enigma Theme Options:
         */
        if ( isset( $wp_customize ) ) {
            $main_panels = $wp_customize->panels();
            foreach( $main_panels as $panel_id => $panel_settings ) {
                if ( 'nav_menus' == $panel_id ) {
                    continue; // keep nav menus
                }
                else if ( 'enigma_theme_option' == $panel_id ) {
                    continue;
                } else {
                    // Everything else gets hidden.
                    $wp_customize->remove_panel( $panel_id );
                }
            }
        }
    }
}

add_action( 'admin_menu', 'ccsd_hide_menus' );
						

Removing Unwanted Customizer Options


/**
 * Hide all Enigma controls except the ones needed for
 * the Slider and Nav Menus:
 */
function ccsd_customizer_filter( $active, $control ) {
    if ( current_user_can( 'edit_theme_options' ) && ! current_user_can( 'edit_themes' ) ) {
        // By default, turn everything off so that we can be selective:
        $active = false;
        
        // Activate the Theme Options / Slider settings
        if ( isset( $control->panel ) && 'enigma_theme_option' == $control->panel ) {
            $active = true;
        }

        if ( isset( $control->section ) && 'slider_sec' == $control->section ) {
            $active = true;
        }

        // Activate the Nav Menus panel and sub-sections:
        if ( isset( $control->id ) && 'nav_menus' == $control->id ) {
            $active = true;
        }

        if ( isset( $control->panel ) && 'nav_menus' == $control->panel ) {
            $active = true;
        }

        if ( isset( $control->section ) && 'menu_locations' == $control->section ) {
            $active = true;
        }

        if ( isset( $control->section ) && 'add_menu' == $control->section ) {
            $active = true;
        }

        if ( isset( $control->section ) && preg_match( '/nav_menu\[/', $control->section ) ) {
            $active = true;
        }
    }
    
    return $active;
}

add_filter( 'customize_control_active', 'ccsd_customizer_filter', 10, 2 );
						

Dashboard Restrictions 2

Electric Boogaloo

Some users are too smart for our own good. And not smart enough for their own good! Even though the Dashboard choices were not seen, they would navigate directly to the URLs for widgets and such. So more drastic measures were required -- a total block from accessing those features.

Enter the ccsd_redirect_naughty_children() function!

We used the Role Editor plugin to create a custom "Webmaster" role, which has the "edit_theme_options" capability, but does not have the "edit_themes" capability. This is how we know that they should not have access to certain things.

Dashboard Restrictions 2

Main Dashboard Widgets / Themes Pages


/**
 * If users try to visit the Themes or Widgets pages by manually altering
 * the URL, slap them on the wrist. Or redirect to the main Dashboard.
 */
function ccsd_redirect_naughty_children() {
	if ( current_user_can( 'edit_theme_options' ) && ! current_user_can( 'edit_themes' ) ) {
		$restrictions = array(
			'/wp-admin/widgets.php',
			'/wp-admin/themes.php',
		);
		
		foreach ( $restrictions as $restriction ) {
			if ( $_SERVER['PHP_SELF'] === $restriction ) {
				$domain = get_current_site()->domain;
				$path = dirname( $_SERVER['REQUEST_URI'] );
				wp_redirect( 'http://' . $domain . $path );
				exit;
			}
		}
	}
}

add_action( 'admin_init', 'ccsd_redirect_naughty_children');
						

Dashboard Restrictions 2

Selective Customizer Access


/**
 * Hide all Enigma controls except the ones needed for
 * the Slider and Nav Menus:
 */
function ccsd_customizer_filter( $active, $control ) {
    if ( current_user_can( 'edit_theme_options' ) && ! current_user_can( 'edit_themes' ) ) {
        // By default, turn everything OFF so that we can be selective:
        $active = false;
        
        /*
         * Now, we selectively allow access to *just* certain Customizer panels,
         * sections, and controls.
         */

        // Activate the Theme Options / Slider settings
        if ( isset( $control->panel ) && 'enigma_theme_option' == $control->panel ) {
            $active = true;
        }

        if ( isset( $control->section ) && 'slider_sec' == $control->section ) {
            $active = true;
        }

        // Activate the Nav Menus panel and sub-sections:
        if ( isset( $control->id ) && 'nav_menus' == $control->id ) {
            $active = true;
        }

        if ( isset( $control->panel ) && 'nav_menus' == $control->panel ) {
            $active = true;
        }

        if ( isset( $control->section ) && 'menu_locations' == $control->section ) {
            $active = true;
        }

        if ( isset( $control->section ) && 'add_menu' == $control->section ) {
            $active = true;
        }

        if ( isset( $control->section ) && preg_match( '/nav_menu\[/', $control->section ) ) {
            $active = true;
        }
    }
    
    return $active;
}

add_filter( 'customize_control_active', 'ccsd_customizer_filter', 10, 2 );
						

Thank You!

I Am...

Dougal Campbell

@dougal

http://dougal.us/

http://dougal.gunters.org/

Questions?

Photo credit: Alex Griffioen, Flickr, CC BY 2.0, http://flic.kr/p/nU3Jv