Adding custom domains to a Django app has one classic trap: ALLOWED_HOSTS. Django validates the Host header on every request and raises DisallowedHost for anything it does not recognise — so dynamic customer domains need dynamic validation, not a wildcard. Clear that, wire up the proxy headers, and the rest is easy. DIY path and shortcut below.
# tenants/middleware.py — validate + resolve customer domains
from django.http import HttpResponseNotFound
from .models import Tenant
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
host = request.get_host().split(':')[0]
if host.endswith('yourapp.com'):
return self.get_response(request)
tenant = Tenant.objects.filter(domain=host).first()
if not tenant:
return HttpResponseNotFound('Unknown domain') # fail closed
request.tenant = tenant
return self.get_response(request)Step 1 — Point DNS at your app
app.customer.com. CNAME proxy.yourapp.com.Apex domains hit the CNAME-at-apex problem. Verify resolution with the CNAME checker.
Step 2 — The ALLOWED_HOSTS gotcha
request.get_host() is only reached after Django validates the host against ALLOWED_HOSTS. A blanket ["*"] makes customer domains work but drops protection against Host-header attacks (cache poisoning, password-reset poisoning). The safer pattern is to validate against your verified tenants. A pragmatic approach: keep your own domains in ALLOWED_HOSTS and add a check that accepts a host only if a tenant exists for it:
# settings.py
ALLOWED_HOSTS = ['yourapp.com', '.yourapp.com']
# A custom validation hook (via middleware ordered before CommonMiddleware,
# or a subclassed host validator) that accepts verified tenant domains only.Cache the tenant existence check; it runs on every request.
Step 3 — Proxy headers
Behind a TLS-terminating proxy, Django needs to be told the truth about scheme and host:
# settings.py
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True # get_host() returns the customer domainOnly set these behind a proxy you control that always sets the header — otherwise a client can spoof HTTPS. For the real client IP, read the leftmost X-Forwarded-For entry (via a trusted-proxy-aware helper), not REMOTE_ADDR, which will be your edge.
Step 4 — SSL in front of Django
Django does not terminate TLS. Use Nginx/Caddy + ACME, or a custom-domains service. Caddy's on-demand TLS issues per-hostname behind an allow-list — trade-offs in cnames.dev vs self-hosting Caddy on-demand TLS.
The one-API-call way
curl -X POST https://api.cnames.dev/v1/domains \
-H "Authorization: Bearer sk_live_..." \
-d '{"hostname":"app.customer.com","origin":"your-django-app.internal:8000"}'cnames.dev handles SSL, renewals, and forwarded headers; your Django middleware and settings stay the same. Common for newsletter and blog platforms. The full architecture is in the complete guide.