tuutti.iki.fi

Serve multiple Drupal instances from one domain

#drupal

We have multiple independent teams working on different parts of the site with a variety of different tech stacks. For example, we have a React team working on an Events section, a couple of Drupal teams working on “Health and Social services” and “Education” sections, etc.

The sections share the same design and domain, and the end user seamlessly moves between sections.

Routing traffic

We have a NGINX server that acts as a reverse proxy to pass requests to different services, for example:

# Pass /en/events and /fi/tapahtumat path to React project.
location ~ ^/(en/events|fi/tapahtumat) {
  proxy_set_header Host events-service.docker.so;
  proxy_pass https://react-backend:443;
}

# Pass /en/social-services, /fi/sosiaali-palvelut and /social-assets/ paths to
# Social services Drupal project.
location ~ ^/(en/social-services|fi/sosiaali-palvelut|social-assets/) {
  proxy_set_header Host social-services.docker.so;
  proxy_pass https://drupal1-backend:443;
}

# Pass /en/education, /fi/koulutus and /education-assets/ paths to
# Education Drupal project.
location ~ ^/(en/education|fi/koulutus|education-assets/) {
  proxy_set_header Host education.docker.so;
  proxy_pass https://drupal2-backend:443;
}

Drupal specific requirements

Drupal requires a unique:

  • Path prefix: All Drupal URLs must be prefixed with a unique path. For example /education/
  • File path prefix: This is required for image styles unless you have some other way to generate them
  • Session cookie name: This is only required if you have more than one Drupal instance

Prefixing all Drupal URLs

While this is not strictly necessary, it will make your life much easier because you have identical paths between the site and reverse proxy. Like https://example.com/en/education/some-url will match the actual Drupal path (https://education.docker.so/en/education/some-url).

This example assumes that you have the language module enabled and use language prefixes.

# yourmodule.services.yml
yourmodule.path_processor:
  class: Drupal\yourmodule\PathProcessor\SitePrefixPathProcessor
  tags:
    # Our inbound processor must be run before PathProcessorFront (200 weight).
    - { name: path_processor_inbound, priority: 201 }
    # Our outbound processor must be run after LanguageProcessorLanguage (100 weight),
    # because we rely on $options['language'] to determine the active language.
    - { name: path_processor_outbound, priority: 99 }
<?php
# src/PathProcessor/SitePrefixPathProcessor.php
declare(strict_types=1);

namespace Drupal\yourmodule\PathProcessor;

class SitePrefixPathProcessor implements
  OutboundPathProcessorInterface,
  InboundPathProcessorInterface {

  private array $prefixes = [
    'en' => 'education',
    'fi' => 'koulutus',
    // Some modules have routes that explicitly emit the language prefix
    // (LANGUAGE_NOT_APPLICABLE), like 'openid_connect'.
    'zxx' => 'education',
  ];

  public function processInbound($path, Request $request) : string {
    $parts = explode('/', trim($path, '/'));
    $prefix = array_shift($parts);

    if (in_array($prefix, $this->prefixes)) {
      $path = '/' . implode('/', $parts);
    }

    return $path;
  }

  public function processOutbound(
    $path,
    &$options = [],
    Request $request = NULL,
    BubbleableMetadata $bubbleable_metadata = NULL
  ) : string {
    if (!isset($options['language'])) {
      return $path;
    }
    $langcode = $options['language'];

    // Use the resolved language to figure out active prefix
    // since it might be different from content language.
    if ($options['language'] instanceof LanguageInterface) {
      $langcode = $options['language']->getId();
    }
    $prefix = $this->prefixes[$langcode] ?? NULL;

    if ($prefix) {
      $options['prefix'] .= $prefix . '/';
    }

    return $path;
  }

}

Unique asset URL

This ensures that all local assets are served directly from the asset path. For example /sites/default/files/styles/xxx/style.jpg will be served from /education-assets/sites/default/files/styles/xxx/style.jpg instead.

<?php
# yourmodule.module
/**
 * Implements hook_file_url_alter().
 */
function yourmodule_file_url_alter(&$uri) : void {
  $assetPath = 'education-assets'

  if (!$assetPath || str_starts_with($uri, $assetPath)) {
    return;
  }
  /** @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $streamWrapperManager */
  $streamWrapperManager = \Drupal::service('stream_wrapper_manager');

  $wrapper = $streamWrapperManager->getViaScheme(
    $streamWrapperManager::getScheme($uri)
  );

  $path = ltrim($uri, '/');

  // Convert public:// paths to relative.
  if ($wrapper instanceof LocalStream) {
    $path = $wrapper
      ->getDirectoryPath() . '/' . $streamWrapperManager::getTarget($value);
  }

  // Override local files only.
  if (!(bool) preg_match('/^(sites|core|themes|modules)\/\w/', $path)) {
    return;
  }
  // Serve element from same domain via relative asset URL. Like:
  // /assets/sites/default/files/js/{sha256}.js.
  $uri = sprintf('/%s/%s', $assetPath, ltrim($path, '/'));
}

Your web server must be able to serve files from that path. Something like this should work with NGINX:

location ~ ^/(?:.*)-assets/(.*)$ {
  proxy_redirect off;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Proto https;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_pass http://127.0.0.1:8080/$1$is_args$args;
}

If you have more than one Drupal instance, then you must add a unique suffix to cookie name so Drupal can differentiate session cookies between instances.

As of writing this, Drupal core does not allow session name to be configured. There’s an issue to address this: #2868384, but for now, you have to decorate the session_configuration service:

# yourmodule.services.yml:
yourmodule.session_configuration:
  class: Drupal\yourmodule\SessionConfiguration
  decorates: session_configuration
  arguments: ['%session.storage.options%']
<?php
# src/SessionConfiguration.php
namespace Drupal\yourmodule;

use Drupal\Core\Session\SessionConfiguration as CoreSessionConfiguration;
use Symfony\Component\HttpFoundation\Request;

/**
 * Decorates the default session configuration service.
 *
 * Append a unique suffix to every session cookie, so we can differentiate
 * session cookies on different Drupal instances using the same domain.
 */
final class SessionConfiguration extends CoreSessionConfiguration {

  private function getSuffix() : string {
    return 'your-session-suffix';
  }

  protected function getName(Request $request) : string {
    return parent::getName($request) . $this->getSuffix();
  }

}