Django Subdomains: Routing & Multi-Tenancy with django-hosts

Blog / Django · June 29, 2017 · Updated June 10, 2026 · 10 min read
Django Subdomains: Routing & Multi-Tenancy with django-hosts

Django routes requests by URL path, not by hostname, so there is no built-in subdomain router. To do subdomain-based routing you read the host yourself with request.get_host() and branch on it, or hand the job to django-hosts, which maps each subdomain to its own URLconf through a declarative host_patterns table. For multi-tenant SaaS where every customer gets tenant.yourapp.com, that same host signal drives tenancy: a shared database with a tenant foreign key, a Postgres schema per tenant via django-tenants, or a separate database per tenant.

This guide rebuilds the classic subdomain middleware for Django 5.x, then shows when to reach for django-hosts or django-tenants instead. If you are new to project layout, start with creating a Django app.

Key takeaways

  • Django has no built-in subdomain routing - read the host from request.get_host() (backed by the HTTP Host header) and act on it.
  • Add a leading-dot wildcard to ALLOWED_HOSTS: ['.example.com'] allows the apex domain and every subdomain.
  • django-hosts is the cleanest declarative option - host_patterns map www, api, and a dynamic (\w+) tenant subdomain to different URLconf modules, with a callback to attach the tenant and {% host_url %} to reverse cross-host URLs.
  • A ~15-line custom middleware that sets request.subdomain is enough when you only need one extra value, not separate URLconfs.
  • For multi-tenant SaaS pick shared-DB + tenant FK (simplest), schema-per-tenant via django-tenants (strong isolation), or DB-per-tenant (hard isolation, most ops).
  • Share logins across subdomains with SESSION_COOKIE_DOMAIN='.example.com', one SECRET_KEY, a shared session store, and CSRF_TRUSTED_ORIGINS = ['https://*.example.com'].

How does Django see the subdomain?

Django never parses the hostname into its URL resolver. Every request carries an HTTP Host header, which Django exposes two ways:

  • request.get_host() - the preferred accessor. It validates the host against ALLOWED_HOSTS, honours USE_X_FORWARDED_HOST when you run behind a proxy, and returns a lowercased host[:port] string such as acme.example.com.
  • request.META['HTTP_HOST'] - the raw, unvalidated header.

Before any of this works, ALLOWED_HOSTS has to accept the subdomains. A single entry with a leading dot covers the apex and all of its subdomains:

# settings.py
ALLOWED_HOSTS = ['.example.com']   # matches example.com, www.example.com, acme.example.com, ...

# Anywhere you hold the request:
host = request.get_host()                       # 'acme.example.com'  (validated + lowercased)
subdomain = host.split(':')[0].split('.')[0]    # 'acme'  (naive; robust parse in the middleware below)

Which subdomain-routing approach should I use?

There are four patterns in common use. The right one depends on whether different subdomains need different URL trees or just a tenant value on the request.

Approach How it works Separate URLconf per subdomain Extra dependency Best for
Custom middleware Parse request.get_host(), set request.subdomain No (branch inside views) None One tenant value; simple apps
Subdomain-aware URLconf Middleware swaps request.urlconf by host Yes (manual) None Custom rules without a library
django-hosts Declarative host_patterns map host to URLconf Yes django-hosts api., admin., www. with different URLs
django-tenants Postgres schema per tenant, routed by domain Optional django-tenants Multi-tenant SaaS needing data isolation

Subdomain routing with django-hosts

django-hosts is the de-facto library for host-based routing. It adds a request middleware (placed first) and a response middleware (placed last), then routes each request to a URLconf chosen from a hosts.py table. Install it and wire up the settings:

# pip install django-hosts

# settings.py
INSTALLED_APPS += ['django_hosts']

MIDDLEWARE = [
    'django_hosts.middleware.HostsRequestMiddleware',    # MUST be first
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django_hosts.middleware.HostsResponseMiddleware',   # MUST be last
]

ROOT_HOSTCONF = 'myproject.hosts'   # the hosts.py module below
DEFAULT_HOST = 'www'                # fallback host pattern name
PARENT_HOST = 'example.com'         # appended when reversing host URLs

The hosts.py table maps a host regex to a URLconf module. List specific subdomains first and the wildcard tenant pattern last, because matching stops at the first hit. A callback runs before the view and is the right place to load the tenant from a captured group:

# myproject/hosts.py
from django.conf import settings
from django_hosts import patterns, host

host_patterns = patterns(
    '',
    host(r'www', settings.ROOT_URLCONF, name='www'),          # www.example.com   -> main site
    host(r'api', 'myproject.api_urls', name='api'),           # api.example.com   -> API URLconf
    host(r'admin', 'myproject.admin_urls', name='admin', scheme='https://'),
    # Dynamic tenant subdomain (acme.example.com, globex.example.com, ...).
    # The named group is handed to the callback, which attaches request.tenant.
    host(
        r'(?P<tenant_slug>[\w-]+)',
        'myproject.tenant_urls',
        callback='myproject.hosts.load_tenant',
        name='tenant',
    ),
)

def load_tenant(request, tenant_slug):
    '''Runs before the view; captured host groups arrive as kwargs.'''
    from django.http import HttpResponseForbidden
    from django.shortcuts import get_object_or_404
    from myproject.models import Tenant
    tenant = get_object_or_404(Tenant, slug=tenant_slug)
    if not tenant.is_active:
        return HttpResponseForbidden('Tenant is not active')
    request.tenant = tenant          # return None to continue on to the view

Inside any view, django-hosts sets request.urlconf to the matched module and exposes the matched host object on request.host (with .name, .urlconf, .scheme, .port). To build links that point at a different host, use the host-aware reverse() from django_hosts.resolvers or the {% host_url %} template tag:

# views.py
from django_hosts.resolvers import reverse

def some_view(request):
    current = request.host.name                 # 'tenant', 'api', 'www', ...
    api_link = reverse('endpoint', host='api')  # //api.example.com/endpoint/
    home = reverse('home', host='www')          # //www.example.com/

# template.html
{% load hosts %}
<a href="{% host_url 'home' host 'www' %}">Home</a>
<a href="{% host_url 'dashboard' host 'tenant' request.tenant.slug %}">Your workspace</a>

Doing it without a library: a custom middleware

If you do not need separate URLconfs, a tiny middleware is enough. Note that modern Django uses the new-style MIDDLEWARE setting (a callable class), not the long-deprecated MIDDLEWARE_CLASSES / process_request you will see in older tutorials. For a refresher on the request/response chain, see how Django middleware works.

# tenants/middleware.py
class SubdomainMiddleware:
    '''New-style middleware: attaches request.subdomain on every request.'''

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

    def __call__(self, request):
        host = request.get_host().split(':')[0]   # strip any :port
        parts = host.split('.')
        # 'acme.example.com' -> 'acme'; treat the bare domain and 'www' as no tenant
        if len(parts) > 2 and parts[0] != 'www':
            request.subdomain = parts[0]
        else:
            request.subdomain = None
        return self.get_response(request)

# settings.py
MIDDLEWARE += ['tenants.middleware.SubdomainMiddleware']

Subdomains and multi-tenancy: which database model?

Subdomain routing is usually the front door to multi-tenancy - one codebase serving many isolated customers, each on customer.yourapp.com. There are three storage models, trading isolation against operational cost:

Model Isolation Where the tenant lives Cross-tenant leak risk Ops cost Best for
Shared DB + tenant FK Logical only A tenant_id column on every table High - every query must filter Lowest Early-stage SaaS, many small tenants
Schema-per-tenant (django-tenants) Strong A separate Postgres schema per tenant Low - router scopes by schema Medium Compliance-sensitive B2B SaaS
Database-per-tenant Hardest A separate database per tenant None Highest Few large/enterprise tenants, data residency

Shared database with a tenant foreign key

The simplest model: one schema, a Tenant row per customer whose slug matches the subdomain, and a tenant foreign key on every tenant-owned table. The catch is discipline - every query must be scoped, so push that into a custom manager rather than repeating .filter(tenant=...) everywhere (see model managers and properties).

# models.py
from django.db import models

class Tenant(models.Model):
    slug = models.SlugField(unique=True)      # matches the subdomain: acme.example.com
    name = models.CharField(max_length=200)
    is_active = models.BooleanField(default=True)

class Project(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='projects')
    name = models.CharField(max_length=200)

# views.py
from django.shortcuts import get_object_or_404, render
from .models import Tenant

def dashboard(request):
    tenant = get_object_or_404(Tenant, slug=request.subdomain, is_active=True)
    projects = tenant.projects.all()          # ALWAYS scope every query by tenant
    return render(request, 'dashboard.html', {'tenant': tenant, 'projects': projects})

Schema-per-tenant with django-tenants

When tenants need real isolation, django-tenants gives each one its own Postgres schema and switches schemas automatically based on the request domain. You split apps into SHARED_APPS (the public schema: tenants, domains, auth) and TENANT_APPS (per-tenant data), and run python manage.py migrate_schemas instead of migrate.

# pip install django-tenants

# settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django_tenants.postgresql_backend',   # schema-aware backend
        'NAME': 'myapp',
        # host / user / password ...
    }
}
DATABASE_ROUTERS = ('django_tenants.routers.TenantSyncRouter',)

MIDDLEWARE = [
    'django_tenants.middleware.main.TenantMainMiddleware',   # first: selects schema by domain
    # ... the rest of your middleware ...
]

TENANT_MODEL = 'customers.Client'         # extends django_tenants.models.TenantMixin
TENANT_DOMAIN_MODEL = 'customers.Domain'  # extends django_tenants.models.DomainMixin

SHARED_APPS = ['django_tenants', 'customers', 'django.contrib.auth', 'django.contrib.contenttypes']
TENANT_APPS = ['django.contrib.contenttypes', 'projects']
PUBLIC_SCHEMA_URLCONF = 'myproject.urls_public'   # URLs for the bare/public domain

How do I keep users logged in across subdomains?

By default a session cookie is scoped to the exact host that set it, so a login on www.example.com does not carry to acme.example.com. To share auth you need four things: a dot-prefixed cookie domain, one shared SECRET_KEY, a shared session store (so any node can read the session), and trusted CSRF origins for the wildcard. The full walkthrough is in maintaining a user session across subdomains in Django.

# settings.py
SECRET_KEY = '...'                                 # MUST be identical across all subdomains
SESSION_COOKIE_DOMAIN = '.example.com'             # leading dot -> shared by every subdomain
CSRF_COOKIE_DOMAIN = '.example.com'
CSRF_TRUSTED_ORIGINS = ['https://*.example.com']   # Django 4.0+: scheme + wildcard required

# Shared store so every server/subdomain reads the same session (not local memory):
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
CACHES = {'default': {'BACKEND': 'django.core.cache.backends.redis.RedisCache',
                      'LOCATION': 'redis://127.0.0.1:6379'}}

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

How do I test subdomains on localhost?

localhost has no subdomains by default, so you have three options for local development:

  • Edit /etc/hosts and point each subdomain at 127.0.0.1.
  • Use a wildcard DNS helper - lvh.me and nip.io resolve any subdomain to 127.0.0.1 with zero config, e.g. acme.lvh.me or acme.127.0.0.1.nip.io. Modern browsers also resolve *.localhost to loopback.
  • Add the dev domains to ALLOWED_HOSTS with a leading dot.

In production, terminate TLS at nginx with a wildcard server_name and a wildcard certificate (*.example.com, issued via a Let's Encrypt DNS-01 challenge), and pass the real Host header through to Django.

# /etc/hosts (manual option)
127.0.0.1   example.local www.example.local acme.example.local api.example.local

# settings.py (local dev) -- or skip /etc/hosts entirely with lvh.me / nip.io
ALLOWED_HOSTS = ['.localhost', '.lvh.me', '.example.local', '127.0.0.1']

# /etc/nginx/conf.d/example.conf
server {
    listen 443 ssl;
    server_name example.com *.example.com;     # wildcard subdomains

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;   # *.example.com cert
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;            # pass the real subdomain through to Django
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Building a multi-tenant Django app?

Subdomain routing is easy to bolt on and hard to retrofit safely once tenant data starts leaking across queries. MicroPyramid has shipped 50+ projects since 2014, including multi-tenant Django SaaS platforms with schema isolation and shared sign-on across subdomains. If you want a second pair of eyes on your tenancy model, see our Django development services.

Frequently Asked Questions

Does Django support subdomain routing out of the box?

No. Django resolves requests by URL path only and ignores the hostname for routing. You read the host yourself via request.get_host() and branch on it, add a custom middleware that sets a request.subdomain attribute, or install django-hosts to map subdomains to separate URLconf modules declaratively. In every case you must also add the subdomains to ALLOWED_HOSTS, for example the wildcard entry .example.com.

What does a leading dot in ALLOWED_HOSTS do?

A leading dot makes the entry match a domain and all of its subdomains. ALLOWED_HOSTS = ['.example.com'] accepts example.com, www.example.com, and any tenant.example.com with a single line, instead of listing each host. Without a matching entry Django returns an HTTP 400 DisallowedHost error.

django-hosts or a custom middleware - which should I use?

Use a custom middleware when every subdomain shares the same URLs and you only need a tenant value on the request; it is about fifteen lines and has no dependency. Use django-hosts when subdomains need different URL trees (for example api., admin., and www.), when you want a dynamic wildcard tenant pattern with a callback, or when you need host-aware URL reversing with the host_url template tag.

How do I give each customer their own subdomain dynamically?

Match a wildcard pattern such as (?P<tenant_slug>[\w-]+) last in the django-hosts table and attach a callback that loads the Tenant by slug and sets request.tenant. With a custom middleware, parse the first label of request.get_host() and look the tenant up. Either way, store a Tenant row whose slug equals the subdomain, and scope every query by that tenant.

How do I share a login session across subdomains?

Set SESSION_COOKIE_DOMAIN = '.example.com' so the session cookie is sent to every subdomain, use one identical SECRET_KEY everywhere, and keep sessions in a shared store such as Redis so any server can read them. For POST requests also set CSRF_TRUSTED_ORIGINS = ['https://*.example.com']. Our dedicated guide on maintaining sessions across subdomains covers the full setup.

Should I use a shared database or a schema per tenant?

Start with a shared database and a tenant foreign key for early-stage products with many small tenants - it is the simplest to run, but every query must be filtered by tenant. Move to schema-per-tenant with django-tenants when you need stronger isolation for compliance or larger B2B customers, and reserve a database per tenant for a small number of enterprise tenants or strict data-residency requirements.

Share this article