How To Use Dynamic Subdomains In Django

How to Handle Multiple (and Dynamic) Domains/Subdomains with Single Django App

If you use a single Django app for admin panel — API back-end, user facing front-end, etc. — you probably use URL schemes like example.com/admin for the admin panel and example.com/api for the API back-end, or something similar.

You should really consider serving each entity on a separate domain or subdomain. Why? Because…

a) Having all entities on a single domain is a security issue.

"Splitting the interfaces over two hostnames—or indeed two port numbers  or protocols (HTTP/HTTPS)—would give you two different JavaScript  origins. This prevents any cross-site-scripting (XSS) attacks that are  accessible on one site from automatically infecting into the other site..." - Answer by bobince on StackExchange

b) Makes it easier to implement separate authentication backend, essentially keeping users with admin access away from those without admin access.

c) Makes use of dynamic domain/subdomains possible.

d) Makes it possible to serve each domain/subdomain from a different server. For example, you can point www.example.com to a high performance server for general users, and admin.example.com to a cheaper one as it’s usually used by a handful of users.

e) It looks neat! ¯\_(ツ)_/¯


We’ll learn two things by the end of this tutorial:

  1. How to serve admin panel, API back-end, and user-facing front-end on different subdomains.
  2. How to serve a single Django app on multiple (and dynamic) domains or subdomains.

Prerequisites

  1. At least some experience with Django development.
  2. A Django app with some URLs configured for at least two of: admin panel, APIs, or user-facing front-end.
  3. Subdomains or domains configured in virtual hosts.

NB: I’ve used Django v2.1, django-hosts v3.0, and Django REST Framework v3.8 for this tutorial.


How to Serve Admin Panel, API, and Front-end on Different Sub-domains

In this section we’ll configure our Django app to serve admin panel on admin.example.com, APIs on api.example.com, and user-facing front-end on www.example.com.

To make our task easier we’ll be using an amazing library by jazzband, django-hosts. Once you’ve installed django-hosts (pip install django-hosts), follow the steps below to configure it.

  1. Add django_hosts to INSTALLED_APPS in settings.py.
  2. Add django_hosts.middleware.HostsRequestMiddleware at the beginning of MIDDLEWARE or MIDDLEWARE_CLASSES in settings.py.
  3. Add django_hosts.middleware.HostsResponseMiddleware at the end of MIDDLEWARE or MIDDLEWARE_CLASSES in settings.py.
  4. Create a file hosts.py in your app. Leave it blank for now, we’ll add content in it later.
  5. Add ROOT_HOSTCONF setting in settings.py to declare path to hosts.py file you just created in previous step. eg. ROOT_HOSTCONF = 'myapp.hosts'

Next, we need to add required content in hosts.py file. Until now, you were probably putting all URL configurations in a single urls.py file. To serve each entity on different subdomains or domains, we need to create separate files similar to urls.py for each set of routes.

For example, I’ll be creating 3 files. Namely, urls.py for admin panel URL configurations, frontend_urls.py for user-facing front-end, and api_urls.py for API back-end.

The structure of each file will be similar, only difference is that urls.py will only contain paths to admin panel routes, api_urls.py will contain paths to only API URLs, and so on. Let’s quickly do that. Your files will look similar to the following:

# api_urls.py
from django.urls import path, include
urlpatterns = [
path('', include('polls.api.urls')),
]
# frontend_urls.py
from django.urls import path, include
urlpatterns = [
path('', include('polls.urls')),
]
# urls.py
from django.contrib import admin
from django.urls import path
urlpatterns = [
path('', admin.site.urls),
]

Once this is done, let’s quickly configure hosts.py to serve each set of URLs on separate subdomains.

from django.conf import settings
from django_hosts import patterns, host
host_patterns = patterns(
'',
host(r'www', 'subdomains_tutorial.frontend_urls', name='www'),
host(r'admin', settings.ROOT_URLCONF, name='admin'),
host(r'api', 'subdomains_tutorial.api_urls', name='api'),
)

You might also need to define DEFAULT_HOST in settings.py.

That’s it! Now you will be able to access each subdomain, and each of them will do its job only. Neat, right?

You should also consider adding django_hosts.templatetags.hosts_override to TEMPLATES['OPTIONS']['builtins'] in settings.py to override default url template tag with template tag bundled with django-hosts to handle subdomains.

I’ve created a sample project with subdomains support. It’s available on GitHub: https://github.com/TheKalpit/Django-Subdomains-Tutorial

What if your requirements are more complex than what django-hosts provide? Don’t worry. I’ve got you covered.


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("\nOperation 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 Managerclass 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


What Else Can You Do?

What I just created is very basic as the purpose of this tutorial was to show how multiple domains can be handled in a single Django app. You can use the above approaches to create more complex apps.

For example, you can create an app in which each super admin user have access to their own domain, and nothing else — similar to “white-labeling” requirement I mentioned above.

You can create apps with single back-end panel and multiple front-end. For example, a news website with multiple domains/subdomains for multiple categories. Such website might require having some of the news articles on a single domain, and some of them on multiple domains. Instead of creating multiple apps, all of them could be managed from a single codebase and admin panel.

You can even combine both of the solutions above to create subdomains for each entity, with support for dynamically created domains. You can use this approach to modify view permissions, authentication, accessible records, and few other things based on currently active domain. Let us know what you’d use it for in your comments below.


Was this tutorial helpful? If you found any error or difficulty following it, let me know in the comments below.


Starting a new project in Django, or looking for a Django developer?

Crowdbotics helps business build cool things with Django (among other things). If you have a Django project where you need additional developer resources, drop us a line. Crowbotics can help you estimate build time for given product and feature specs, and provide specialized Django developers as you need them. If you’re building with Django, check out Crowdbotics.


If you like this post, you might also like, How to write an API in 3 lines of code with Django REST framework

Thanks to William Wickey for help editing.