DoDomain
Guides

Custom domains for a multi-tenant Django app

Resolve the tenant from request.get_host() in Django 5 middleware, get ALLOWED_HOSTS right for domains you cannot list up front, mint the DNS records with the dodomain-sdk, verify the signed webhook in Python, and decide where TLS terminates on gunicorn behind Caddy or nginx.

A multi-tenant Django app gets three separate problems the day a customer asks for app.acme.com instead of acme.yourproduct.com: the app has to know which tenant a hostname belongs to, the customer's DNS has to point that hostname at you and stay pointed, and something has to hold a certificate for a domain you do not own. Django adds a fourth that bites first: ALLOWED_HOSTS, which cannot list a hostname a customer will add tomorrow and answers 400 Bad Request to every one you did not. This page covers all four in the order they bite, and ends with the two answers that are not obvious — where TLS terminates on a Django stack, and what to say when a customer wants the bare acme.com.

The examples use Django 5 and dodomain-sdk (pip install dodomain-sdk, httpx its only dependency); the webhook verifier is also shown in pure standard library. Nothing here depends on a particular database or tenancy package.

1. Resolve the tenant from request.get_host()

request.get_host() is the right accessor: it validates the header, reads X-Forwarded-Host when USE_X_FORWARDED_HOST is on, and strips the port. Resolve the tenant in a middleware placed early in MIDDLEWARE, and attach the result to the request so views and templates never touch the header:

tenants/middleware.py
from django.http import HttpResponseNotFound

from tenants.models import Tenant

PRIMARY_HOST = "yourproduct.com"


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

    def __call__(self, request):
        host = request.get_host().lower()
        request.tenant = None

        if host not in (PRIMARY_HOST, f"www.{PRIMARY_HOST}"):
            # A tenant on your own subdomain (acme.yourproduct.com) or on a
            # custom domain (app.acme.com). Both come from the same table — one
            # row per hostname a tenant is allowed to serve — and only ACTIVE
            # rows count.
            request.tenant = Tenant.objects.find_by_active_hostname(host)
            if request.tenant is None:
                return HttpResponseNotFound()

        return self.get_response(request)

find_by_active_hostname is a manager method against a table you own. The rest of this guide is about how rows get into that table honestly: not when a customer types a domain into a form, but when DNS actually resolves to you.

Do not trust the form

A customer typing app.acme.com into your settings page proves nothing. Store the domain as pending, and only mark it active — only let the middleware serve it — when a connection.verified webhook says the records are live. Otherwise a typo in a competitor's hostname routes their traffic to a 404 on your infrastructure.

The Django-specific trap: ALLOWED_HOSTS

get_host() raises DisallowedHost — a 400 — for any hostname not matched by ALLOWED_HOSTS, and that check runs before your middleware sees the request. ["yourproduct.com", ".yourproduct.com"] covers your own subdomains and nothing else; there is no setting that says "and whatever is in my tenants table". The two honest options:

settings.py
# Option A — Django keeps validating; the edge is the source of truth.
# Every hostname that reaches gunicorn was already admitted by Caddy's
# on-demand-TLS `ask` check or nginx's server_name (section 4), which consult
# the SAME active-domains table. Django therefore trusts its proxy for the
# host header and, since the edge refuses unknown hosts, accepts any host.
ALLOWED_HOSTS = ["*"]
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

ALLOWED_HOSTS = ["*"] is only acceptable when something in front of Django refuses hosts it does not serve — the DNS-rebinding protection the setting exists for is "reject hosts that are not yours", and an edge that only forwards active custom domains does exactly that. With ["*"], the middleware above becomes the host check: it returns 404 for any hostname that is neither your primary host nor an active tenant hostname, so put it first in MIDDLEWARE.

Option B keeps the explicit list and never serves customer domains from Django directly: a proxy such as Cloudflare for SaaS rewrites the customer's hostname to <tenant>.yourproduct.com before the request reaches you, and ALLOWED_HOSTS = [".yourproduct.com"] stays exact. Which one you pick is decided by section 4, not here.

One more setting matters on a custom domain: a form POST from https://app.acme.com passes Django's CSRF origin check only when request.is_secure() is true, which behind a TLS-terminating proxy needs SECURE_PROXY_SSL_HEADER set as above. Without it every tenant form fails with a CSRF error while your own domain works — the symptom that usually gets misdiagnosed as a cookie problem.

2. Mint the records with a connect session

Your server mints one session per domain, describing the records your product needs. For a subdomain that is a single CNAME to your ingress hostname; add a TXT if you want an ownership proof you can check yourself. dodomain-sdk wraps POST /api/v1/sessions:

tenants/views.py
import os

from django.shortcuts import redirect
from django.views.decorators.http import require_POST
from dodomain import DnsRecord, DoDomain

from tenants.models import CustomDomain

dodomain = DoDomain(secret_key=os.environ["DODOMAIN_SECRET_KEY"])


@require_POST
def add_custom_domain(request):
    domain = request.POST["domain"].strip().lower()

    session = dodomain.sessions.create(
        domain=domain,
        records=[DnsRecord(type="CNAME", host="@", value="cname.yourproduct.com")],
        return_url=f"https://{request.tenant.slug}.yourproduct.com/settings/domains",
    )

    # pending until the webhook says otherwise
    CustomDomain.objects.create(
        tenant=request.tenant, hostname=domain, dodomain_session_id=session.id
    )

    return redirect(session.connect_url)

host="@" means "the domain itself" — the domain you passed is app.acme.com, so the CNAME lands on app.acme.com, not on acme.com. Read session.records[].fqdn if you want to confirm what will be verified. A 402 becomes QuotaExceededError, a 400 becomes InvalidRequestError; the Python SDK page has the full table, and the same call over raw HTTPS is POST https://app.dodomain.io/api/v1/sessions with Authorization: Bearer dd_sk_…. Send the customer to connect_url. On Cloudflare-hosted zones the record is written with one OAuth consent; on a provider that has enabled Domain Connect for DoDomain it is one click; everywhere else the hosted page shows that provider's exact panel steps and verifies each record live. You do not branch on any of that.

3. Activate the tenant from the webhook, not the redirect

The customer coming back to return_url means they closed the flow, not that DNS is live. The signal you act on is the signed connection.verified event. Every delivery carries an x-dodomain-signature header of the form t=<unix ms>,v1=<hex>, where v1 is the HMAC-SHA256 of t + "." + rawBody keyed with the endpoint's whsec_... secret (the full scheme). The SDK ships verify_webhook(secret, raw_body, header); if you would rather not depend on it in the webhook path, the whole verifier is standard library:

tenants/dodomain_webhook.py
import hmac
import re
import time
from hashlib import sha256

SIGNATURE_HEADER = "x-dodomain-signature"
DEFAULT_TOLERANCE_MS = 5 * 60 * 1000
_HEX_64 = re.compile(r"^[0-9a-f]{64}$")


def verify_dodomain_signature(
    secret: str,
    raw_body: bytes,
    header: str,
    tolerance_ms: int = DEFAULT_TOLERANCE_MS,
    now_ms: int | None = None,
) -> bool:
    """Verifies the exact bytes DoDomain sent. Never pass a re-serialized dict —
    the signature covers the raw body."""
    parts: dict[str, str] = {}
    for pair in header.split(","):
        key, _, value = pair.partition("=")
        parts[key.strip()] = value.strip()

    try:
        t = int(parts.get("t", ""))
    except ValueError:
        return False
    now = now_ms if now_ms is not None else int(time.time() * 1000)
    if t <= 0 or abs(now - t) > tolerance_ms:
        return False

    # A malformed signature is a failed verification, not an exception:
    # shape-gate before the constant-time compare.
    got = parts.get("v1", "")
    if not _HEX_64.match(got):
        return False

    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, sha256).hexdigest()
    return hmac.compare_digest(got, expected)
tenants/webhooks.py
import json
import os

from django.db import IntegrityError
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from tenants.dodomain_webhook import SIGNATURE_HEADER, verify_dodomain_signature
from tenants.models import CustomDomain, WebhookReceipt


@csrf_exempt
@require_POST
def dodomain_webhook(request):
    if not verify_dodomain_signature(
        os.environ["DODOMAIN_WEBHOOK_SECRET"],
        request.body,  # the raw bytes, never request.POST
        request.headers.get(SIGNATURE_HEADER, ""),
    ):
        return HttpResponse("bad signature", status=401)

    event = json.loads(request.body)

    # Dedupe on the event id — retries reuse it. A unique column does it atomically.
    try:
        WebhookReceipt.objects.create(event_id=event["id"])
    except IntegrityError:
        return HttpResponse(status=204)

    domain = event["data"]["domain"]
    match event["type"]:
        case "connection.verified":
            # First verification, or recovery after a drift (data.recovered is True).
            CustomDomain.objects.activate(domain)
        case "connection.failed":
            # scope "connection": a verified domain drifted and stayed wrong for
            # three consecutive checks. scope "session": an attempt failed
            # mid-flow — the row is still pending, nothing to deactivate.
            if event["data"].get("scope") == "connection":
                CustomDomain.objects.deactivate(domain)
        case "connection.disconnected":
            CustomDomain.objects.deactivate(domain)

    return HttpResponse(status=204)

The view is csrf_exempt (an inbound webhook carries no CSRF token) and reads request.body, never request.POST, because the signature covers the bytes on the wire. Map it with path("webhooks/dodomain", dodomain_webhook) and register the URL as an endpoint in the dashboard, which gives you the whsec_... secret. The webhooks page has the full event table and the retry schedule.

activate is what flips the row the middleware reads. Once active, the domain stays watched: DoDomain re-checks every connection's records against authoritative DNS on every plan, at no charge — every 10 minutes for the first day, hourly for the first week, every 6 hours after that. A customer who deletes the CNAME months later produces a connection.failed, which your handler turns into a deactivated row instead of a tenant silently serving a 404 on their own domain. Details in Automatic DNS drift monitoring.

4. Where TLS terminates on a Django stack

This is the part multi-tenant guides gloss over. DoDomain is a DNS layer and is never in your request path, so it holds no certificate for app.acme.com — the certificate is issued wherever your Django app terminates TLS, and connection.verified is the moment issuance can succeed, because DNS finally resolves to that host. gunicorn (or uvicorn) terminates nothing in a normal deployment; what sits in front of it does — and, per section 1, that same layer is what makes ALLOWED_HOSTS = ["*"] safe.

gunicorn behind Caddy. Caddy's on_demand_tls issues a certificate the first time it sees a new hostname, and its ask endpoint lets it check with you before doing so. Point ask at a tiny Django view that answers 200 only for active custom domains — the same table the middleware reads, populated by the webhook — and Caddy will never mint a certificate for, or forward, a hostname that has not verified:

Caddyfile
{
	on_demand_tls {
		ask http://127.0.0.1:8000/internal/tls-ask
	}
}

https:// {
	tls {
		on_demand
	}
	reverse_proxy 127.0.0.1:8000
}
tenants/tls_ask.py
from django.http import HttpResponse

from tenants.models import CustomDomain


def tls_ask(request):
    # Caddy sends ?domain=<hostname>. 200 permits issuance; anything else denies.
    hostname = request.GET.get("domain", "").lower()
    ok = CustomDomain.objects.is_active(hostname)
    return HttpResponse(status=200 if ok else 404)

Caddy sets X-Forwarded-Host and X-Forwarded-Proto on the way through, which is what USE_X_FORWARDED_HOST and SECURE_PROXY_SSL_HEADER in section 1 consume. The session's CNAME points at the Caddy host.

gunicorn behind nginx. nginx holds the certificates, and certbot (or acme.sh) obtains one per customer domain with an HTTP-01 challenge — which only succeeds once DNS points at the box. Trigger issuance from the webhook: connection.verified enqueues a task that runs certbot for that hostname, writes a server block for it, and reloads nginx. Keep the default_server block that returns 444, as Django's deployment checklist recommends — that is what refuses hosts you do not serve while ALLOWED_HOSTS is ["*"].

Cloudflare for SaaS. Create a custom hostname for the domain and have the session's CNAME point at your zone's fallback origin. Cloudflare validates through DNS and issues the edge certificate. Your origin sees the customer's hostname in X-Forwarded-Host, or — if you configure an origin rewrite to <tenant>.yourproduct.com — an explicit ALLOWED_HOSTS keeps working unchanged (option B above).

The sequencing rule that holds for every host — add the domain on the platform, mint the session pointing at it, treat connection.verified as "issuance can proceed" — and the Vercel, Render and Fly.io notes are on the SSL page.

5. Apex survival: what to say to "can I use acme.com itself?"

DNS forbids a CNAME at a zone's apex, so acme.com → cname.yourproduct.com is not a record any provider will accept, and DoDomain cannot create the provider-specific ALIAS/ANAME/flattened substitute for your customer. Decide the policy before the first customer asks, and enforce it in your form's clean(), not in the connect flow:

  1. Ask for a subdomain (app.acme.com, links.acme.com). One CNAME, every provider, every one-click path, and the middleware needs no change. dodomain.domains.check(domain=...) returns both the domain and the zone that owns its records; when the two are equal the customer gave you an apex, and that is the moment to raise a ValidationError that says what to do.
  2. Serve the apex from A/AAAA records if — and only if — your ingress has stable, documented IPs (an anycast edge, a load balancer with reserved addresses). Request them at "@" on acme.com. Those addresses become a contract with every connected customer.
  3. www plus a redirect at the provider. The customer points www.acme.com at you with a CNAME (verified and monitored), and sets apex-to-www forwarding in their DNS panel. Be explicit that the redirect is theirs: DoDomain neither creates it nor watches it.

The reasoning behind each, the CNAME-flattening providers, and the warnings a session returns for an apex request are on the Apex domains page.

Checklist

  • The tenant middleware runs first, resolves from request.get_host(), and serves only active rows.
  • ALLOWED_HOSTS = ["*"] is paired with an edge that refuses unknown hosts (Caddy ask, nginx 444), plus USE_X_FORWARDED_HOST and SECURE_PROXY_SSL_HEADER; or the edge rewrites to <tenant>.yourproduct.com and the explicit list stays.
  • Domains enter as pending; only connection.verified activates them; connection.failed (scope connection) and connection.disconnected deactivate them.
  • The webhook view is csrf_exempt, verifies request.body with the verifier above (or the SDK's verify_webhook), and dedupes on id.
  • Certificates come from where TLS terminates (Caddy on-demand TLS, nginx + certbot, Cloudflare for SaaS) — never from DoDomain.
  • The apex policy is decided in your form: subdomain by default, A/AAAA only with stable IPs, www + provider redirect as the fallback.

See also

  • The connect flow — what your customer sees on the hosted page, provider by provider.
  • DNS provider setup guides — the same panel steps the hosted flow shows, one page per provider, for your own support docs.
  • Free tools — the provider detector and the authoritative-vs-public DNS lookup, for debugging a customer's zone without a session.
  • DoDomain with Cloudflare for SaaS — the seam between the DNS half and the TLS half, if that is your edge.

On this page