How to Serve a Single Django App on Multiple (and Dynamic) Domains/Subdomains

In one of my previous projects, the client wanted to develop an app in Django that could also be sold as a white-labelled service.

From a development point of view, this requires a single Django app which can run on multiple, dynamically created, domains or subdomains. It should also be able to filter content and handle permissions based on the domain from which it is accessed.

Unfortunately, django-hosts can’t be used as it required hardcoded configuration, and it only supports subdomains. So, I created a custom solution.

Let’s start with a fresh Django app, and create an app named domains using the command python manage.py startapp domains . Don’t forget to add it in INSTALLED_APPS in settings.py . Create a model in this app’s models.py file as below.

from django.db import models class Domain(models.Model):

domain = models.CharField(max_length=128, unique=True)

name = models.CharField(max_length=128) def __str__(self):

return self.domain

We’ll be using this model to create domains dynamically. You can simply create a domain example.com in admin panel and a new website will be functional on example.com using the existing codebase and database.

Of course, you will need to configure the domain in virtual hosts. The configuration for the same will vary depending upon the server software you’re using.

Now that you’ve created the Domain model, let’s add it to Django Admin quickly.

from django.contrib import admin from .models import Domain

class DomainAdmin(admin.ModelAdmin):

fields = ('domain', 'name')

ordering = ('domain',)

admin.site.register(Domain, DomainAdmin)

Creating django-admin Command to Create Domain from Command Line:

Since we’re binding the app to specific domains, including the default one, the app might not be accessible if there are no domains added in the database. To tackle this situation, having a django-admin command is very handy.

To do so, create a python package named management in domains app. Create a regular directory named commands inside it. In commands directory create a file createdomain.py , and add the following code in it. I’ll explain it below.

import sys from django.core import exceptions

from django.core.management.base import BaseCommand, CommandError

from django.db import DEFAULT_DB_ALIAS

from django.utils.text import capfirst from domains.models import Domain

class NotRunningInTTYException(Exception):

pass

class Command(BaseCommand):

help = 'Used to create a domain.'

requires_migrations_checks = True

stealth_options = ('stdin',) def __init__(self, *args, **kwargs):

super().__init__(*args, **kwargs)

self.DomainModel = Domain

self.domain_field = self.DomainModel._meta.get_field('domain')

self.name_field = self.DomainModel._meta.get_field('name') def add_arguments(self, parser):

parser.add_argument(

'--%s' % 'domain',

dest='domain', default=None,

help='Specifies the domain.',

)

parser.add_argument(

'--database', action='store', dest='database',

default=DEFAULT_DB_ALIAS,

help='Specifies the database to use. Default is "default".',

)

parser.add_argument(

'--%s' % 'name', dest='name', default=None,

help='Specifies the name',

) def execute(self, *args, **options):

self.stdin = options.get('stdin', sys.stdin) # Used for testing

return super().execute(*args, **options) def handle(self, *args, **options):

domain = options['domain']

name = options['name']

database = options['database'] verbose_domain_field_name = self.domain_field.verbose_name

verbose_name_field_name = self.name_field.verbose_name # Enclose this whole thing in a try/except to catch

# KeyboardInterrupt and exit gracefully.

try: if hasattr(self.stdin, 'isatty') and not self.stdin.isatty():

raise NotRunningInTTYException("Not running in a TTY") while domain is None:

input_msg = '%s: ' % capfirst(verbose_domain_field_name)

domain = self.get_input_data(self.domain_field, input_msg)

if not domain:

continue

if self.domain_field.unique:

try:

self.DomainModel._default_manager.db_manager(database).get_by_natural_key(domain)

except self.DomainModel.DoesNotExist:

pass

else:

self.stderr.write("Error: That %s is already in use." % verbose_domain_field_name)

domain = None if not domain:

raise CommandError('%s cannot be blank.' % capfirst(verbose_domain_field_name)) while name is None:

input_msg = '%s: ' % capfirst(verbose_name_field_name)

name = self.get_input_data(self.name_field, input_msg)

if not name:

continue if not name:

raise CommandError('%s cannot be blank.' % capfirst(verbose_domain_field_name)) except KeyboardInterrupt:

self.stderr.write("

Operation cancelled.")

sys.exit(1) except NotRunningInTTYException:

self.stdout.write(

"Domain creation skipped due to not running in a TTY. "

) if domain and name:

domain_data = {

'domain': domain,

'name': name,

}

self.DomainModel(**domain_data).save()

if options['verbosity'] >= 1:

self.stdout.write("Domain created successfully.") def get_input_data(self, field, message, default=None):

"""

Override this method if you want to customize data inputs or

validation exceptions.

"""

raw_value = input(message)

if default and raw_value == '':

raw_value = default

try:

val = field.clean(raw_value, None)

except exceptions.ValidationError as e:

self.stderr.write("Error: %s" % '; '.join(e.messages))

val = None return val

It’s simply taking user input and creating a record in Domain model. The code is similar to the one Django itself uses to create super user (i.e. createsuperuser ).

You can run the following command in CLI to create a domain:

python manage.py createdomain

Creating Middleware to Detect Current Domain:

So far we’ve created a model to define domain names, and a CLI command to add records in it. We plan to isolate data on each domains. To do so, first we need to let our Django app know which domain is currently accessing the app. That’s a job for a middleware. Let’s create one.

Create a file middleware.py in domain app, with the content below:

from django.utils.deprecation import MiddlewareMixin

class CurrentDomainMiddleware(MiddlewareMixin):

def process_request(self, request):

from .models import Domain

request.domain = Domain.objects.get_current(request)

This might not make any sense at this point, that’s because we’re actually doing the actual job of detecting domain in domains/models.py for efficiency reasons.

In domains/models.py create a custom Manager class as below:

from django.db import models

from django.http.request import split_domain_port DOMAINS_CACHE = {}

class DomainManager(models.Manager):

use_in_migrations = True def _get_domain_by_id(self, domain_id):

if domain_id not in DOMAINS_CACHE:

domain = self.get(pk=domain_id)

DOMAINS_CACHE[domain_id] = domain

return DOMAINS_CACHE[domain_id] def _get_domain_by_request(self, request):

host = request.get_host()

try:

if host not in DOMAINS_CACHE:

DOMAINS_CACHE[host] = self.get(domain__iexact=host)

return DOMAINS_CACHE[host]

except Domain.DoesNotExist:

domain, port = split_domain_port(host)

if domain not in DOMAINS_CACHE:

DOMAINS_CACHE[domain] = self.get(domain__iexact=domain)

return DOMAINS_CACHE[domain] def get_current(self, request=None, domain_id=None):

if domain_id:

return self._get_domain_by_id(domain_id)

elif request:

return self._get_domain_by_request(request) def clear_cache(self):

global DOMAINS_CACHE

DOMAINS_CACHE = {} def get_by_natural_key(self, domain):

return self.get(domain=domain)

Next step? Use this class in Domain model.

objects = DomainManager()

Don’t forget to add this middleware path, domains.middleware.CurrentDomainMiddleware in my case, to MIDDLEWARE in settings.py .

We’re almost there. All we’ve done so far will give us Domain object attached to each request. You can access it by request.domain anywhere request is available. Let’s test it.

Put it to Test:

Create another app python manage.py startapp polls and add it to INSTALLED_APPS in settings.py . Create any models you want in this app, just add a ForeignKey or ManyToManyField in it named domain or domains respectively.

Register the model(s) in admin.py as well, but don’t keep domain or domains field editable. We’ll be assigning these fields automatically while saving the model. We’ll also be using currently active domain to filter records. This is how I’ve done it:

# models.py from django.db import models from domains.models import Domain

class Poll(models.Model):

content = models.CharField(max_length=256)

domain = models.ForeignKey(Domain, on_delete=models.CASCADE) def __str__(self):

return self.content

class PollOption(models.Model):

poll = models.ForeignKey(Poll, on_delete=models.CASCADE, related_name='poll_options')

value = models.CharField(max_length=256) def __str__(self):

return self.value # admin.py from django.contrib import admin from polls.models import PollOption, Poll class PollOptionInline(admin.TabularInline):

model = PollOption

fields = ('value',)

extra = 1

class PollAdmin(admin.ModelAdmin):

inlines = (PollOptionInline,)

fields = ('content', 'domain',)

readonly_fields = ('domain',) def get_queryset(self, request):

qs = Poll.objects.filter(domain=request.domain)

ordering = self.get_ordering(request)

if ordering:

qs = qs.order_by(*ordering)

return qs def save_model(self, request, obj, form, change):

obj.domain = request.domain

return super().save_model(request, obj, form, change)

admin.site.register(Poll, PollAdmin)

That’s it! You may now create any many domains/subdomains as you want and each one of them will have access to only the polls created within that environment.

Checkout the GitHub project with complete code here: https://github.com/TheKalpit/Django-Dynamic-Domains-Tutorial