ccnames.dev

cnames.dev / blog

Tutorial12 min read

How to add custom domains to your Django app

Custom domains in Django: the ALLOWED_HOSTS gotcha, SECURE_PROXY_SSL_HEADER behind a TLS-terminating proxy, host-based tenant routing, and SSL. DIY and the easy way.

S
Saeed
June 27, 2026

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 domain

Only 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.

Building a SaaS that needs custom domains? cnames.dev gives every customer their own domain — SSL, edge routing, and per-tenant isolation in one API call. Free for 25 domains, no demo call. Start free · Read the docs

Frequently asked questions

Why does Django raise DisallowedHost for custom domains?

Django validates the Host header against ALLOWED_HOSTS and raises SuspiciousOperation (DisallowedHost) for anything not listed. Since customer domains are dynamic, validate them in middleware against your tenants table rather than hard-coding, and avoid a blanket ["*"] in production.

Is ALLOWED_HOSTS = ["*"] safe for multi-tenant custom domains?

It removes Host-header validation, which protects against cache poisoning and password-reset poisoning. Prefer middleware that sets request accepted hosts to only verified customer domains, or override the host validation to check your database.

How does Django know the request is HTTPS behind a proxy?

Set SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") so request.is_secure() is correct, and USE_X_FORWARDED_HOST = True so get_host() returns the customer domain, not the internal host. Only do this behind a proxy you trust that always sets the header.

Keep reading

S

SaeedFounder, cnames.dev — runs custom-domain infra for 5,000+ production sites at Lindo.ai.