# Custom domains for a multi-tenant Django app (https://dodomain.io/docs/guides/custom-domains-multi-tenant-django)

Custom domains in multi-tenant Django 5: resolve the tenant from the host, set ALLOWED_HOSTS, mint DNS records, verify webhooks, place TLS.

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`](https://dodomain.io/docs/python-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()` [#1-resolve-the-tenant-from-requestget_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:

```python title="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.

<Callout title="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.
</Callout>

### The Django-specific trap: `ALLOWED_HOSTS` [#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:

```python title="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. It is the symptom that usually gets misdiagnosed as a cookie problem.

## 2. Mint the records with a connect session [#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`:

```python title="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](https://dodomain.io/docs/python-sdk#errors) 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 [#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](https://dodomain.io/docs/webhooks#verifying-signatures)). 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:

```python title="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)
```

```python title="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 with the `add_webhook_endpoint` tool or `POST /api/v1/webhook-endpoints`, which returns the `whsec_...` secret. The [webhooks page](https://dodomain.io/docs/webhooks) 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](https://dodomain.io/docs/webhooks#automatic-dns-drift-monitoring).

## 4. Where TLS terminates on a Django stack [#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:

```text title="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
}
```

```python title="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](https://dodomain.io/docs/ssl).

## 5. Apex survival: what to say to "can I use acme.com itself?" [#5-apex-survival-what-to-say-to-can-i-use-acmecom-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](https://dodomain.io/docs/apex-domains) page.

## Checklist [#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 [#see-also]

- [The connect flow](https://dodomain.io/docs/connect-flow): what your customer sees on the hosted page, provider by provider.
- [DNS provider setup guides](https://dodomain.io/docs/guides/dns-providers): the same panel steps the hosted flow shows, one page per provider, for your own support docs.
- [Free tools](https://dodomain.io/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](https://dodomain.io/compare/cloudflare-for-saas): the seam between the DNS half and the TLS half, if that is your edge.
