Many applications and frameworks have the ability to host multiple application instances on a single code base. Usually such applications are older, and date from when hosting a site was considerably more expensive than it is today. That made squeezing many small sites onto one code base a good cost-cutting measure in some cases.
Today, with more efficient, container-based hosting systems and multi-site platforms (like Platform.sh), such functionality is much less useful. The challenges such a configuration poses become a bigger concern when they're not offset by large cost savings, making it rarely a good idea to leverage such multisite management anymore.
Why not go multisite?
Multi-site hosting on a single code base means:
- Code updates (including configuration) apply to all of them at once; you have to QA, deploy, and test all of them at once.
- Code customizations (including configuration) per site are impossible (or, depending on the system, very difficult).
- A bug that takes down one site is going to take down all of them.
- A scheduled downtime that takes down one site is going to take down all of them.
- Because containers usually have more limited resources than the large VM they're running on, the sites will be competing for resources more often.
- All of the sites are sharing the same resources, so extra load on one popular site will impact all of them.
- Backups may become more difficult.
- Some back-end services may become harder to segment between sites.
Another problem is that the "site router" functionality, which decides which of the many hosted sites to load, is usually domain-name based. On Platform.sh, by necessity, every branch has a different domain name. That means the domain-matching logic gets considerably more complex, since the domain itself is highly variable depending on the branch you're on.
In general, at Platform.sh we discourage people from using such multisite configurations. Usually, they end up being more trouble than they're worth. That said, there are use cases where they make sense and in those cases we do support them.
When is multisite appropriate?
I would argue a multisite configuration is appropriate when:
- All of the sites are individually low traffic.
- All of the sites are legitimately "the same site," just for different data. For instance, multiple small departments or multiple nearly identical products.
- The number of sites is reasonably low, say under a dozen.
- The same person or team is responsible for maintenance on all of the sites.
- You have a robust enough QA process in place to QA all of the sites at once when there’s an update.
If that's the case, then a multisite setup may be worth considering. But how do you configure it?
Making multisite manageable
For demonstration purposes, we'll show the key moving parts of a multisite configuration using Drupal 8, one of the most common systems used for multisite configurations. The process for Drupal 7 is largely similar, but with some adjustments. For other applications, the tasks will be largely the same, but the code will, of course, be different.
For simplicity. we'll assume that all of the sites we're hosting are subdomains off of a single common domain. If that's not the case then it gets a bit more complex. As long as there is a regular pattern, though, it should be possible.
You can follow-along at home with our Drupal 8 Multisite template, available on GitHub.
We'll walk through setting up a two-site installation, first.example.com
and second.example.com
. That helps keep the code straightforward.
Set up the routes
We'll start with a separate route per subsite in routes.yaml
, like so:
"https://first.{default}/":
type: upstream
upstream: "app:http"
cache:
enabled: true
# Base the cache on the session cookie and custom Drupal cookies. Ignore all other cookies.
cookies: ["/^SS?ESS/", "/^Drupal.visitor/"]
"https://second.{default}/":
type: upstream
upstream: "app:http"
cache:
enabled: true
# Base the cache on the session cookie and custom Drupal cookies. Ignore all other cookies.
cookies: ["/^SS?ESS/", "/^Drupal.visitor/"]
That's the same route, repeated multiple times. You can’t use a wildcard route for two reasons: first, Platform.sh will not be able to provision Let's Encrypt TLS certificates automatically; second, we need discrete routes later for the application parsing to work.
Note that Let's Encrypt only allows up to 100 certificates to be requested at once, so that’s effectively the hard limit on how many multisites you can host this way. (You’re likely to run into other issues long before that, however.)
Set up the database
Next, any services that support multiple databases should be configured with a separate database per site instance. For MySQL or MariaDB, for example, you would put the following in services.yaml
:
db:
type: "mariadb:10.2"
disk: 2048
configuration:
schemas:
- firstdb
- seconddb
endpoints:
first:
default_schema: firstdb
privileges:
firstdb: admin
second:
default_schema: seconddb
privileges:
seconddb: admin
This configuration creates two separate databases on a single instance of MariaDB, with an endpoint for each. While you could put each site on its own service instance, that’s largely a waste of resources and not recommended.
Note that we're using the name of the subdomain as the name of the endpoint. That's not strictly required, but by using the same identifier key all the way through the system, we make the configuration much easier and less manual.
If you plan to use another service that supports multiple databases, such as Solr, do the same configuration there. Other services, such as Redis or Elasticsearch, handle separate databases entirely at runtime. You'll need to ensure your application code is setup to handle that correctly.
Expose the relationships
Each database endpoint needs to then be exposed to the application. Again, we'll name the relationships according to the site prefix (first
and second
) so we can look them up dynamically. From .platform.app.yaml
:
relationships:
rediscache: "cache:redis"
first: "db:first"
second: "db:second"
Application configuration
The steps above apply to any application. The rest of this example will be Drupal 8-specific, but the requirements and concepts apply to any application.
Drupal handles multisite by mapping the incoming domain and path to a sites/X
directory, and then loading the settings.php
file it finds in that directory. If one isn't found, it uses sites/default
, which is the directory a single-site setup always uses. Drupal's logic for mapping the request to a directory is a bit involved, but fortunately can be overridden by a lookup array produced by the sites/sites.php
file. That's where the core of the Platform.sh logic lives. In our example it looks like this:
$platformsh = new \Platformsh\ConfigReader\Config();
if (!$platformsh->inRuntime()) {
return;
}
foreach ($platformsh->getUpstreamRoutes($platformsh->applicationName) as $route) {
$host = parse_url($route['url'], PHP_URL_HOST);
if ($host !== FALSE) {
$subdomain = substr($host, 0, strpos($host,'.'));
$sites[$host] = $subdomain;
}
}
The first part loads the Platform.sh Config Reader, which simplifies access to the environment variables. In particular, the getUpstreamRoutes()
call retrieves a filtered list of the routes defined in the PLATFORM_ROUTES
environment variable, and specifically just those pointing at the currently running application. (Recall that Platform.sh auto-generates various redirect routes for HTTP->HTTPS redirection and so forth; this function filters those out for you.)
The routes in PLATFORM_ROUTES
will, at this point, already be expanded to the full domain appropriate for the current environment. This is where the "any branch" logic comes in. The list of domains Drupal will respond to is built dynamically at runtime from the list of available routes.
The domain list is then processed to build a lookup table of hostnames to just the left-most portion of the domain name. In our example, the lookup table will have two entries: one pointing to first
and one to second
. If you were using some other pattern for your subsites, you would modify this code accordingly. (And if you have no pattern at all, this code gets very ugly and manual.)
Drupal will then include the sites/first/settings.php
or sites/second/settings.php
file as appropriate, and assume that its configuration is ready. Both files must. therefore, exist, but in our case they’re short stubs. In a typical Drupal 8 site on Platform.sh, settings.php
is fairly minimal and just includes sites/default/settings.platformsh.php
, which is where the Platform.sh-specific bits live.
In a multisite configuration, we have opted to have a single, combined sites/settings.platformsh.php
file that gets included by both/all sites. However, that means setting a variable first so that the combined file knows what subsite is active. The code for that looks like:
$platformsh_subsite_id = basename(__DIR__);
$config_directories[CONFIG_SYNC_DIRECTORY] = '../config/sync/' . $platformsh_subsite_id;
// Automatic Platform.sh settings.
if (file_exists($app_root . '/' . $site_path . '/../settings.platformsh.php')) {
include $app_root . '/' . $site_path . '/../settings.platformsh.php';
}
The "subsite ID" is the name of the directory, which fortuitously lines up perfectly with the subdomain name and with the database relationship. (That's why we did that.) We can then use that value when setting the $config_directories
setting value, and it’s also available in settings.platformsh.php
. That way, the database configuration can use the $platformsh_subsite_id
value to find the right database credentials for that site:
$creds = $platformsh->credentials($platformsh_subsite_id);
if ($creds) {
$databases['default']['default'] = [
'driver' => $creds['scheme'],
'database' => $creds['path'],
'username' => $creds['username'],
'password' => $creds['password'],
'host' => $creds['host'],
'port' => $creds['port'],
'pdo' => [PDO::MYSQL_ATTR_COMPRESS => !empty($creds['query']['compression'])]
];
}
The subsite ID can also be used in the Redis configuration as a cache prefix so that both sites aren't writing to the same cache pool and stomping all over each other's caches.
$settings['cache_prefix'] = $platformsh_subsite_id . '_';
And for splitting up the upload file directories:
if (!isset($settings['file_public_path'])) {
$settings['file_public_path'] = 'files/' . $platformsh_subsite_id;
}
And so on. See the settings.platformsh.php
file in its entirety for the full story.
The specifics will vary if you’re using a system other than Drupal 8, or a different subsite pattern, or just prefer to handle it differently. The basic model, though, is the same:
- Use the
PLATFORM_ROUTES
list to build a map from the dynamically generated, branch-specific domain to a known key. - Use that key to connect the application to the right relationship endpoints. The more regular and predictable the pattern of subsites, the easier this step is.
- Whenever possible, use separate databases on a single service.
- Remember that the application may have many other site-specific configuration values beyond just the database connection.
Another option: launch a fleet
If you really do have a good use case for a multisite application, that's the overall way to do it. For Drupal 8 you can use our ready-made template. For other applications you'll need to apply the same process.
Alternatively, a frequently better approach is to set up a "fleet" of single-instance sites instead, using a common code base. Managing a fleet on Platform.sh is highly flexible, and there are many ways to do so, depending on your use case.
Not sure what approach is best for your site? Drop us a line, and our Solutions team can help you architect the multi-site hosting solution for your collection of sites.