IAN WALDRON IAN WALDRON

How to Handle Multiple Domains in Django with the Sites Framework

How to use the Django Sites framework in combination with custom middleware for supporting multiple domains pointing to discrete locations with a single Django application.
May 17, 2024

Introduction

I found myself in a situation where I wanted to run a single Django application supporting multiple discrete sites with independent domains. The sites themselves needed to be independent but they were to share enough core functionality that it was convenient to use one backend. In the past, I would break this problem into multiple services consisting of an API powering different frontend services. Imagine something like:

           API 

/ \

site-1.com site-2.com

These days, however, I find myself increasingly drawn to the monolith: a single project handling both backend and frontend responsibilities.

Why Monoliths?

Why take a monolithic approach? I'm tired of over complicating things far earlier than what the problem warrants. We can break apart components into their own services later. Instead, let's get up and running as quickly as possible and increase complexity as we scale.

Favoring simplicity doesn't mean we can't anticipate scale and structure our monolith in such a way makes breaking out services into discrete parts easier. That's the point of this article is to do just that. But until that point where services are best maintained separately, let's keep the project to a single codebase.

Separate Components, One Project

First, what's the real world problem I'm trying to solve? Imagine a project needing a public frontend at example.com, an admin page at admin.example.com, and perhaps a SAAS component at app.example.com. I want the admin and app to exist on independent domains in anticipation of being independent services. When that time comes, I don't want to redirect traffic to a new location.

Instead, I'll structure a project where these components will be discrete locations within one project. Each component will have their own URL root and behave from the user perspective as independent services. The structure would look something like:

project/ 
|-- app/
|-- admin/
|-- project/
| |-- apps.py
| |-- asgi.py
| |-- settings.py
| |-- urls.py
| `-- wsgi.py
|-- public/
`-- manage.py

With app/admin/, and public/ existing at app.example.com, admin.example.com and example.com respectively, we'll use middleware to dynamically override Django's ROOT_URLCONF project setting.

Middleware

We'll use middleware to tell our project where the root URL configuration is that we'd like to use based on what host is present in the request. At this point we could use the Django Sites Framework to help out. But if all we're trying to do is dynamically set the root URL configuration, using middleware alone will be much lighter and avoid database calls.

Django makes it relatively easy to dynamically set the root URL configuration with the HttpRequest object's urlconf attribute. Normally the root URL configuration is determined by your project setting's ROOT_URLCONF. However if request.urlconf is set, this value will override the value set in settings.py.

Bring this together with checking the current host and setting the root URL configuration will look something like:

class MultiSiteMiddleware:

def __init__(self, get_response):
    self.get_response = get_response

def __call__(self, request):
    domain = request.get_host().split(':')[0]
    
    if domain.endswith('admin.example.com'):
        request.urlconf = 'admin.urls'
    elif domain.endswith('app.example.com'):
        request.urlconf = 'app.urls'
    elif domain.endswith('example.com'):
        request.urlconf = 'public.urls'

First we grab everything to the left side of the url root using the Django HttpRequest object's get_host() method. Just incase there is a port specified, we split the string on ':' and take everything to the left. This is necessary to for the project to work in development where you're probably running a dev server on '127.0.0.1:8000.'

Then, we run through each of our hosts and set the root URL configuration accordingly. Note, you'll wan't to specify subdomains like 'admin.example.com' before 'example.com' or traffic on the subdomains will just route to the second-level domain; 'example.com' in this case.

Be sure you activate this middleware by adding the full python path of the middleware class to the MIDDLEWARE list in settings.py. That's all you need to do to start routing traffic dynamically based on the host.

Caution

This middleware will still allow traffic if the host doesn't match. If there isn't a match, traffic will route to the root URL configuration in settings.py. By default, this is going to be 'project.urls.' Since the host probably won't be in the ALLOWED_HOSTS configuration unless a wildcard is used, there probably won't be a problem outside of configuration issues. If the middleware handles all cases defined in ALLOWED_HOSTS, traffic not handled by the middleware would be caught with a SuspiciousOperation exception. But let's handle the case anyways for completeness and to help us debug if we make a mistake. This view and URL patter will get the job done:

# views.py
from django.http import Http404


# noinspection PyUnusedLocal
def bad_request(request):
    raise Http404("Unrecognized location.")


# urls.py
from django.urls import path

from .views import bad_request


# root urlconf is determined by middleware
# if we ended up here, there's a problem
urlpatterns = [
    path('', bad_request)
]

The exception SuspiciousOperation could also be used like for DisallowedHosts instead of using Http404. But if you go down that path then subclass the exception and provide a useful exception message.

Django Sites Framework

Enter, Django Sites Framework. As previously mentioned, using sites isn't necessary if all we want to do is direct traffic based on the current host. In fact, adding the overhead of database calls to grab the current site probably isn't desirable. If we want to go a step further and associate content with a site, render content differently, and so on, then we'll benefit from using this framework. See this discussion on example use cases.

Let's restructure the above middleware to retrieve the current host from a sites

from django.contrib.sites.models import Site


class MultiSiteMiddleware:
    """
    Route to the appropriate root URL conf based on current host.
    """
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # this section if not using Site
        # domain = request.get_host().split(':')[0]
        # if domain.endswith('admin.example.com'):
        #     request.urlconf = 'admin.urls'
        # elif domain.endswith('app.example.com'):
        #     request.urlconf = 'app.urls'
        # elif domain.endswith('example.com'):
        #     request.urlconf = 'public.urls'

        # Note! get_current() hits the db with every request
        # Django only caches the Site object for the given request
        # possible point of optimization if requests are slow: store the
        #   Site object in sessions and only requery if get_host() has
        #   a different domain than the current Site (or upon expiration)
        domain = Site.objects.get_current(request).domain

        if domain == 'admin.example.com':
            request.urlconf = 'admin.urls'
        elif domain == 'app.example.com':
            request.urlconf = 'app.urls'
        elif domain == 'example.com':
            request.urlconf = 'public.urls'

        return self.get_response(request)

GitHub

The domain is determined by using the get_current method of the Site object. Then, the middleware checks the domain against the locations specified and sets urlconf accordingly.

A couple of important things to note here. We're determining the current site dynamically so make sure SITE_ID is NOT in settings.py or your get_current will determine the active site based on the ID specified by this setting. Which brings up an interesting point. Why not use IDs instead of a domain? For example: if site.id == 1. Django recommends against hardcoding IDs:

It’s fragile to hard-code the site IDs like that, in case they change. The cleaner way of accomplishing the same thing is to check the current site’s domain[.]

Another item worth mentioning, using the Sites Framework like this means your project doesn't function without having the site records loaded into the database. For testing, you may want to use Django fixtures to auto populate those records if you're creating and destroying databases often.

Final Thoughts

Keep it simple. There's no reason to break out components into separate services until scale opportunities make it advantageous to do so. Fortunately Django makes it really easy to manage multiple sites in one project whether or not you're using the Sites Framework or not. With a bit of simple middleware, you can dynamically route traffic and give the user the impression of distinct projects.