Engineering

How to Add Custom Domains to a Django App

Drago Crnjac
Drago Crnjac
CEO & Founder
Cover Image for How to Add Custom Domains to a Django App

To add custom domains to a Django app, each customer points a DNS record at a proxy that holds an HTTPS certificate for their domain, you store that domain on their account, and a middleware maps the incoming hostname to the right tenant. This guide uses SaaS Custom Domains as that proxy, so certificates, renewals and the edge servers aren't your problem; the Django side is what's left.

On the Django side it comes down to a model, two small middlewares and a second URLconf. Everything here works on Django 5.2 LTS and 6.x, and the guide spends extra time on the errors Django apps hit once real customer domains arrive: "Invalid HTTP_HOST header", ALLOWED_HOSTS, and forms rejected with "Origin checking failed".

What's covered:

Run the example app

All the code below comes from a working project on GitHub, custom-domains-django. It's a small status page product with two seeded companies, Intercom and Globex. You need Docker:

git clone https://github.com/saas-custom-domains/custom-domains-django
cd custom-domains-django
docker compose up --build

Open http://localhost:8000, give Intercom the domain intercom.localhost, and wait a few seconds for its badge to say Live. Then load http://intercom.localhost and subscribe to updates with any email address. That request goes through scd-mock, a stand-in for our API and proxy listening on port 80, so Django receives the headers a production request would carry. Browsers and curl send any *.localhost name to your own machine, so no hosts file edits are needed.

Tested with
Django6.1.2
Python3.13

Versions last checked on October 29, 2026.

From a DNS record to a Django request

The example product is a status page service at app.statuspage.com (our Rails guide uses it too). One of its customers, Intercom, would like its status page on status.intercom.com.

Intercom's DNS doesn't point at your Django servers. It points at the SaaS Custom Domains proxy, which terminates HTTPS for status.intercom.com and forwards the request to your app:

  • status.intercom.com is a subdomain, so it gets a CNAME to in.saascustomdomains.com. For customers who shouldn't see our hostname at all, you can offer a white-label target of yours, such as in.statuspage.com.
  • An apex domain such as intercomstatus.com has no room for a CNAME, so it gets A records with the proxy's two static IPs.
; Subdomain
status.intercom.com.   CNAME  in.saascustomdomains.com.

; Root domain
intercomstatus.com.    A      75.2.96.173
intercomstatus.com.    A      99.83.186.151

The proxy then sends the request on to app.statuspage.com. It keeps your app's hostname in Host, so your hosting platform routes it as usual, and adds the customer's domain in an X-Served-For header plus a secret token that proves the request came from the proxy. The DNS section of our custom domains guide explains the record types in more depth.

Save customer domains in a model

Each CustomDomain row ties one hostname to one tenant and records whether its DNS is in place yet, so Django never serves a domain that isn't really pointed at it. The tenant here is a Company:

domains/models.py
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models

hostname = RegexValidator(r"^[a-z0-9-]+(\.[a-z0-9-]+)+$")


def not_one_of_ours(host):
    ours = settings.APP_DOMAIN  # "statuspage.com"
    if host == ours or host.endswith("." + ours):
        raise ValidationError("Can't be one of our own domains.")


class CustomDomain(models.Model):
    class Status(models.TextChoices):
        PENDING = "pending"
        ACTIVE = "active"

    company = models.ForeignKey(
        "companies.Company",
        on_delete=models.CASCADE,
        related_name="custom_domains",
    )
    host = models.CharField(
        max_length=253,
        unique=True,
        validators=[hostname, not_one_of_ours],
    )
    status = models.CharField(
        max_length=10, choices=Status.choices, default=Status.PENDING
    )
    # Set by the tls_certificate_issued webhook.
    https_ready = models.BooleanField(default=False)
    # The domain's UUID at SaaS Custom Domains, to check and remove it.
    provider_uuid = models.CharField(
        max_length=36, unique=True, null=True, blank=True
    )
    created_at = models.DateTimeField(auto_now_add=True)

    def save(self, *args, **kwargs):
        self.host = self.host.strip().lower().rstrip(".")
        super().save(*args, **kwargs)

Things worth knowing about this model:

  • Keep unique=True on host. If two companies could save the same domain, the lookup would return one of them at random and the other company's visitors would see the wrong status page.
  • Normalize before you validate. full_clean() runs the validators on the value as typed, so Status.Intercom.com. fails the regex because of the trailing dot, even though it's the same host as status.intercom.com. The fix is a helper that lowercases, strips the dot and converts international names to punycode, called before full_clean():
domains/utils.py
def normalize_host(value):
    # "Status.Intercom.com." and "status.intercom.com" are the same host
    host = value.strip().lower().rstrip(".")
    try:
        # Internationalized domains are stored as punycode.
        return host.encode("idna").decode()
    except UnicodeError:
        return host  # the model's validators reject it
  • not_one_of_ours blocks your own domain and its subdomains, so nobody can register admin.statuspage.com as their "custom" domain.
  • provider_uuid keeps the domain's UUID at SaaS Custom Domains, for checking and deleting it later. https_ready turns on when the proxy reports that the certificate was issued.

A domain stays pending until the proxy has confirmed its DNS. The status comes from our API's DNS check and from webhooks, both shown further down, so the app never resolves DNS records itself.

Resolve the tenant from the hostname

Django exposes the requested hostname as request.get_host(), port included. Behind the proxy, the customer's domain is in X-Served-For instead. Keep the code that reads it in one helper:

domains/utils.py
def host_from(request):
    # Behind SaaS Custom Domains, Host is your own hostname and the
    # customer's domain is in X-Served-For. ProxyTokenMiddleware has
    # already rejected the request if the proxy's token was missing.
    host = request.headers.get("X-Served-For") or request.get_host()
    return host.split(":")[0].lower()

Without the header, as in local testing on *.localhost, the function falls back to get_host(). A middleware then looks the host up, attaches the company to the request, and swaps in a URLconf that only has the customer-facing pages:

domains/middleware.py
# Load balancers and Docker call the health check by IP address, which
# is neither an app host nor a custom domain.
HEALTH_CHECK_PATH = "/up"


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

    def __call__(self, request):
        request.company = None
        if request.path == HEALTH_CHECK_PATH:
            return self.get_response(request)

        host = host_from(request)

        if host not in settings.APP_HOSTS:
            domain = (
                CustomDomain.objects.select_related("company")
                .filter(host=host, status=CustomDomain.Status.ACTIVE)
                .first()
            )
            if domain is None:
                raise Http404("Unknown domain")

            request.company = domain.company
            request.urlconf = "statuspage.urls_custom_domains"

        return self.get_response(request)
statuspage/urls_custom_domains.py
from django.urls import path

from status import views

urlpatterns = [
    path("", views.status_page, name="status_page"),
    path("subscribe", views.subscribe, name="subscribe"),
]

The status page has a subscribe form, which matters later for CSRF. Both views take the company from the request, never from the form:

status/views.py
def status_page(request, form=None, message=None):
    data = request.company.fetch_status_data()
    return render(
        request,
        "status/page.html",
        {
            "data": data,
            "form": form or SubscribeForm(),
            "message": message,
        },
    )


@require_POST
def subscribe(request):
    # The company comes from the hostname, never from the form, so one
    # customer's page can't subscribe people to another's.
    form = SubscribeForm(request.POST)
    if not form.is_valid():
        return status_page(request, form=form)
    Subscriber.objects.get_or_create(
        company=request.company, email=form.cleaned_data["email"]
    )
    message = f"Subscribed to {request.company.name} status updates."
    return status_page(request, message=message)

The settings that go with it:

statuspage/settings.py
APP_DOMAIN = os.environ.get("APP_DOMAIN", "statuspage.com")
APP_HOSTS = env_list("APP_HOSTS", "app.statuspage.com,localhost")

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "domains.middleware.ProxyTokenMiddleware",
    "domains.middleware.CustomDomainMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

(env_list splits a comma-separated environment variable. ProxyTokenMiddleware shows up in the proxy section.) What this setup gives you:

  • request.urlconf is Django's own switch for per-request URLs. A custom domain can reach the status page and the subscribe view, and nothing else: no dashboard, no webhooks, no admin. On app.statuspage.com, /subscribe is a 404.
  • Unknown hosts, and domains that aren't active yet, get a 404. A stranger who aims their own domain at your IP sees nothing.
  • host_from is the single function that decides which host a request is for.

Health checks and internal hostnames

Two kinds of requests arrive on hostnames that aren't in either list. Load balancers and Docker health checks call the app by IP address, such as 10.0.0.7:8000. Webhooks and internal services often use a private name; in the example's docker compose setup, the mock sends webhooks to app:8000. Both would get the "Unknown domain" 404.

The middleware skips /up before looking at the host, and every internal name your own traffic uses goes into APP_HOSTS. In the example that's APP_HOSTS=localhost,app.

Using django-tenants?

django-tenants maps hostnames to tenants (and their Postgres schemas) through its own Domain model, so a custom domain is one more row:

Domain.objects.create(
    domain="status.intercom.com", tenant=intercom, is_primary=False
)

Pass is_primary=False. The field defaults to True, and saving a primary domain demotes the tenant's other domains, so the customer's new domain would become the one django-tenants uses for that tenant before its DNS even works. django-tenants also has no pending state: it routes a domain as soon as the row exists, so create it after the DNS check passes.

TenantMainMiddleware reads request.get_host(), so the sections below still apply. One difference: it catches DisallowedHost and returns a bare 404, so with django-tenants a domain missing from ALLOWED_HOSTS shows up as "Not Found" instead of a 400.

Fix "Invalid HTTP_HOST header" errors

As soon as Django sees a customer's domain as the host while ALLOWED_HOSTS is a normal list, the request fails with 400 Bad Request, and the log says:

Invalid HTTP_HOST header: 'status.intercom.com'.
You may need to add 'status.intercom.com' to ALLOWED_HOSTS.

ALLOWED_HOSTS exists to stop Host header attacks, such as a password reset email whose link points at an attacker's site. It's a static list (.example.com covers subdomains), and there's no way to keep thousands of customer domains in it.

So the example sets ALLOWED_HOSTS = ["*"] and lets the middleware do that job instead. It's safe as long as:

  • CustomDomainMiddleware sits directly after SecurityMiddleware (with only ProxyTokenMiddleware in between), ahead of anything that builds a page or sends email.
  • It answers 404 for every host that isn't in APP_HOSTS or an active custom domain, as the code above does.

The proxy sends your own hostname in Host, so purely read-only pages would work with a regular list. The example hands Django the customer's domain so that forms and links work, as described below, and from then on "*" is required.

Why every custom domain needs its own certificate

Browsers load status.intercom.com over HTTPS, and they only accept a certificate issued for that exact hostname. Your wildcard for *.statuspage.com doesn't cover it. So every domain a customer connects needs its own certificate, issued when the domain arrives and renewed every few months for as long as it's connected.

Running that yourself means owning:

  • issuance on demand: when an unfamiliar hostname shows up in a TLS handshake, get it a certificate on the spot, and turn away hostnames no customer has connected, or anyone could drain your Let's Encrypt quota
  • a certificate store shared by every server that answers on your IPs
  • renewal jobs, plus a backup certificate authority for the days your main one is down
  • a pair of fixed IP addresses for apex domains, since customers copy them into DNS once and expect them to work forever
  • alerts and an on-call rotation for issuance failures, certificates close to expiry, and customers who quietly moved their DNS elsewhere

Our custom domains guide goes into each of these.

SaaS Custom Domains takes care of every item on that list. The remaining sections wire Django up to it.

Connect Django to SaaS Custom Domains

With SaaS Custom Domains, customers point their domains at our proxy. We issue and renew the certificates (Let's Encrypt and ZeroSSL), run the proxy in 119 AWS locations, and pass every request on to Django together with the customer's domain. You get an API and webhooks to manage domains from your app, two static IPs for root domains, and, if you want them, emails that walk your customers through the DNS records.

You pay $0.29 for each domain every month. The minimum is 100 domains, so the smallest bill is $29, and volume pricing starts at 1,000 domains. You can try it for 7 days for free; you enter a card at signup and can cancel at any point.

The setup, with rough times:

  1. Get an API token (about a minute)
  2. Register your Django app as an upstream (about a minute)
  3. Register domains when customers add them (around 5 minutes)
  4. Have the customer update their DNS (about a minute, on their side)
  5. Read the customer's domain in Django and verify our token (5 to 15 minutes)

1. Get an API token

Create a token under API tokens in the account settings.

The API tokens page in the SaaS Custom Domains account settings.

The account settings page shows your account UUID:

Account settings page with the account UUID.

Store the two as SCD_API_TOKEN and SCD_ACCOUNT_UUID in your environment. The token gives full control over your domains, so it doesn't belong in your repository.

2. Register your Django app as an upstream

It's how our proxy knows where to send traffic: your Django app. Here's a small client for the API calls the app needs, built on requests:

domains/saas_domains.py
# A small client for the SaaS Custom Domains API.
# Docs: https://docs.saascustomdomains.com/custom-domains
import os

import requests
from django.conf import settings


class SaasDomains:
    def __init__(self):
        self.base = f"{settings.SCD_API_URL}/api/v1/accounts"
        self.account = os.environ["SCD_ACCOUNT_UUID"]
        self.session = requests.Session()
        token = os.environ["SCD_API_TOKEN"]
        self.session.headers["Authorization"] = f"Bearer {token}"

    def create_upstream(self, host):
        return self._request("POST", "upstreams", {"host": host})

    def create_domain(self, host, **options):
        body = {"host": host, **options}
        return self._request("POST", self._domains(), body)

    def verify_dns(self, uuid):
        path = f"{self._domains()}/{uuid}/verify_dns_records"
        return self._request("POST", path)

    def delete_domain(self, uuid):
        try:
            return self._request("DELETE", f"{self._domains()}/{uuid}")
        except requests.HTTPError as e:
            # Already gone is fine: the goal is that it doesn't exist.
            if e.response.status_code != 404:
                raise

    def _domains(self):
        upstream = os.environ["SCD_UPSTREAM_UUID"]
        return f"upstreams/{upstream}/custom_domains"

    def _request(self, method, path, body=None):
        url = f"{self.base}/{self.account}/{path}"
        res = self.session.request(method, url, json=body, timeout=10)
        res.raise_for_status()
        return res.json()

SCD_API_URL defaults to https://app.saascustomdomains.com; the example points it at the mock. An upstream is a one-time thing, so python manage.py shell is a fine place to create it:

>>> from domains.saas_domains import SaasDomains
>>> SaasDomains().create_upstream("app.statuspage.com")
{'uuid': 'your_upstream_uuid',
 'host': 'app.statuspage.com',
 'port': 443,
 'tls': True,
 'auth_token': '3f9c...',
 ...}

Two values from that response are needed later. The uuid goes in SCD_UPSTREAM_UUID, and the auth_token goes in PROXY_AUTH_TOKEN for step 5. You can also create upstreams in the dashboard; every option is in the upstreams API docs.

3. Register domains when customers add them

When a customer enters a domain in your app, save it and send it to our API in one service function. The provider_uuid field from the model keeps our UUID so you can check or delete the domain later:

domains/services.py
def add_custom_domain(company, host, dns_contact_email=""):
    domain = CustomDomain(company=company, host=normalize_host(host))
    domain.full_clean()
    domain.save()

    # Whoever manages their DNS gets step-by-step instructions
    recipient = dns_contact_email or company.dns_contact_email
    options = {"instructions_recipient": recipient} if recipient else {}
    try:
        res = SaasDomains().create_domain(domain.host, **options)
    except Exception:
        # Don't keep a domain the proxy doesn't know about.
        domain.delete()
        raise
    domain.provider_uuid = res["uuid"]
    domain.save(update_fields=["provider_uuid"])
    return domain

How it behaves:

  • full_clean() raises a ValidationError for an invalid host, one of your own domains, or a domain another company already has. Show its messages next to the form.
  • instructions_recipient is sent only when there's an address, since dns_contact_email is a blank string by default. With it, we email the DNS records to that person, useful since the one typing the domain into your app often has no access to DNS. Leave it out to send instructions yourself.
  • When creating the domain on our side fails, the function deletes the row again. Otherwise a domain we never heard of would stay pending forever and block the hostname for everyone.

You can move the call into a background task to keep it out of the request. The custom domains API docs describe every field.

4. Have the customer update their DNS

The customer adds the CNAME or the two A records from the start of this guide. If the instructions email is on, they already have the exact records. Old A records for the same name must be deleted; otherwise part of the traffic still reaches the previous host. DNS changes typically apply within minutes.

Cloudflare users have one more setting to change: the record has to be DNS only (the grey cloud). With the orange cloud, the name resolves to Cloudflare's IPs, and both the DNS check and certificate issuance fail.

Cloudflare DNS record with the proxy status set to DNS only.

Some customers need Cloudflare's proxy and will keep the orange cloud. For those, pass challenge_type="dns01" to create_domain and have them add the extra _acme-challenge CNAME from the API response. We then get the certificate through DNS validation instead.

5. Read the customer's domain in Django

Requests from our proxy carry your app's own hostname, app.statuspage.com, in Host, and the same value in X-Forwarded-Host. That's on purpose: your hosting platform routes on it, and apps that only answer on their own domain keep working. The customer's domain arrives separately, in X-Served-For, and host_from above already reads it.

Verify the proxy's auth token

X-Served-For is just a header. Anyone who can reach Django directly, without going through the proxy, could send X-Served-For: status.intercom.com with any Host and have the request treated as Intercom's domain.

So our proxy adds your upstream's auth token to every request, in X-SaaS-Domains-Auth-Token (docs), and this middleware refuses any request that uses X-Served-For without it. hmac.compare_digest takes the same time no matter how much of a guess is right:

domains/middleware.py
class ProxyTokenMiddleware:
    # List it before CustomDomainMiddleware in settings.MIDDLEWARE

    def __init__(self, get_response):
        self.get_response = get_response
        # Unset or empty means no request may use X-Served-For.
        self.expected = os.environ.get("PROXY_AUTH_TOKEN", "").encode()

    def __call__(self, request):
        served_for = request.headers.get("X-Served-For")
        if served_for:
            token = request.headers.get("X-SaaS-Domains-Auth-Token", "")
            if not self.expected or not hmac.compare_digest(
                token.encode(), self.expected
            ):
                return HttpResponseForbidden()

            # Host is still your own hostname. The token proves the
            # request came through the proxy, so give Django the
            # customer's domain: the CSRF Origin check, redirects and
            # build_absolute_uri() all read request.get_host().
            request.META["HTTP_HOST"] = served_for

        return self.get_response(request)

Check for an empty expected token as well. hmac.compare_digest(b"", b"") is True, so with PROXY_AUTH_TOKEN set to an empty string, a request with no token header at all would pass.

The token check is about the header: only the proxy can use X-Served-For to say which customer domain a request is for. It doesn't hide public pages. Anyone can open status.intercom.com anyway, and a request sent straight to your server with Host: status.intercom.com gets the same public page the proxy would return. Keep the token in the environment, and generate a new one in the dashboard if it might have leaked.

Fix "Origin checking failed" on forms

For read-only pages, nothing more is needed. When the app needs to know the customer's domain, for CSRF checks on forms, redirects or absolute links, Django has to see that domain in request.get_host(). Django's CSRF middleware compares the browser's Origin with it, and without the last line of ProxyTokenMiddleware, every form on a custom domain returns 403 Forbidden with "CSRF verification failed. Request aborted." The log shows:

Forbidden (Origin checking failed - https://status.intercom.com
does not match any trusted origins.): /subscribe

CSRF_TRUSTED_ORIGINS can't fix it, because like ALLOWED_HOSTS it's a fixed list. Setting request.META["HTTP_HOST"] after the token check does: from then on request.get_host(), request.build_absolute_uri(), redirects and the CSRF check all use status.intercom.com. Three settings go with it, two of them in settings.py:

statuspage/settings.py
ALLOWED_HOSTS = ["*"]

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
  • ALLOWED_HOSTS = ["*"], because get_host() now returns customer domains. CustomDomainMiddleware still answers 404 for hosts that aren't yours or active.
  • SECURE_PROXY_SSL_HEADER, because TLS ends at our proxy. Without it Django thinks the request is plain HTTP, expects an http:// origin, and the form fails with the same message.
  • Leave USE_X_FORWARDED_HOST at its default, False. With it on, Django reads X-Forwarded-Host, which holds your own hostname, and the form fails again.

(If you'd rather have our proxy put the customer's domain in X-Forwarded-Host and use USE_X_FORWARDED_HOST = True, support can switch that on for your account.)

Build links for your own app, like password reset emails, from a setting instead of request.get_host(), since the host is now whatever domain the request came in on.

Don't set SESSION_COOKIE_DOMAIN or CSRF_COOKIE_DOMAIN. Left alone, Django sets cookies for the exact host that served them, which is the right behaviour on a domain that belongs to your customer. If they're set to .statuspage.com so sessions work across your subdomains, browsers will refuse those cookies on every custom domain.

Show DNS status and handle webhooks

"Is my domain working yet?" is the support question you'll get most. Give customers a "Check DNS" button that asks our API to look at their records right away:

domains/services.py
def check_dns(domain):
    res = SaasDomains().verify_dns(domain.provider_uuid)
    if res["dns_status"] == "valid":
        domain.status = CustomDomain.Status.ACTIVE
        domain.save(update_fields=["status"])
    return res["dns_status"]  # "valid", "pending" or "invalid"

To get updates without polling, add a webhook in the dashboard. Each delivery includes the webhook's security token as a token query parameter. Check it first, and refuse everything if the expected token isn't configured:

domains/views.py
STATUS_BY_EVENT = {
    "domain_dns_proxy_record_valid": CustomDomain.Status.ACTIVE,
    "domain_dns_proxy_record_invalid": CustomDomain.Status.PENDING,
}


@csrf_exempt
@require_POST
def saas_domains_webhook(request):
    # Events: https://docs.saascustomdomains.com/webhooks
    token = request.GET.get("token", "")
    expected = os.environ.get("SCD_WEBHOOK_TOKEN", "")
    if not expected or not hmac.compare_digest(
        token.encode(), expected.encode()
    ):
        return HttpResponse(status=401)

    try:
        payload = json.loads(request.body)
        event, uuid = payload["event"], payload["domain"]["uuid"]
    except (ValueError, KeyError, TypeError):
        # Not a domain event (e.g. upstream_created) or not JSON.
        return HttpResponse(status=200)

    domains = CustomDomain.objects.filter(provider_uuid=uuid)
    status = STATUS_BY_EVENT.get(event)
    if status:
        domains.update(status=status)
    elif event == "tls_certificate_issued":
        domains.update(https_ready=True)

    return HttpResponse(status=200)

With status and https_ready, the dashboard can show three states: "Waiting for DNS", "DNS OK, waiting for HTTPS", and "Live". Events that aren't about a domain, and bodies that can't be parsed, get a 200 so they aren't retried for nothing. Other events include domain_created and domain_deleted; the webhooks docs have them all. Answer quickly with a 2xx: we retry a failed delivery as many as 10 times. The webhook arrives on your app's hostname, so that hostname must be in APP_HOSTS.

Removing a domain

Remove it from our proxy before you delete the row:

domains/services.py
def remove_custom_domain(domain):
    # Proxy first, database second: if the API call fails, the row is
    # still here and the user can try again.
    if domain.provider_uuid:
        SaasDomains().delete_domain(domain.provider_uuid)
    domain.delete()

Should the API call fail, the row stays put and the customer can click Remove again. The opposite order can leave a domain live on the proxy that your app no longer knows about. delete_domain treats a 404 as success, so a domain that's already gone on our side doesn't get stuck in your list. Once the domain is gone from the proxy, its traffic stops reaching your app.

Test custom domains on localhost

You don't need real domains to try any of this. Chrome, Firefox and curl resolve every *.localhost name to 127.0.0.1, and Django's default for an empty ALLOWED_HOSTS with DEBUG = True already accepts .localhost hosts. Run python manage.py runserver, then add a domain from the shell:

>>> from companies.models import Company
>>> company = Company.objects.first()
>>> company.custom_domains.create(
...     host="intercom.localhost", status="active"
... )

http://intercom.localhost:8000 now shows Intercom's status page, and http://localhost:8000 is still the dashboard.

To try the proxy path by hand, send the headers yourself:

curl http://localhost:8000/ \
  -H "X-Served-For: intercom.localhost" \
  -H "X-SaaS-Domains-Auth-Token: $PROXY_AUTH_TOKEN"

Without the token header, the response is a 403.

The example app goes further: docker compose up starts scd-mock next to Django, and requests for http://intercom.localhost pass through it with production's headers. Production always sends X-Forwarded-Proto: https, but the mock speaks plain HTTP, so the browser's origin is http://intercom.localhost. If the mock claimed https, Django would expect an https:// origin and reject the form with Origin checking failed - http://intercom.localhost does not match any trusted origins. The compose file sets SCD_MOCK_FORWARDED_PROTO=http for that reason. The repo's Playwright tests send the browser's requests for *.test domains to the mock as an HTTP proxy, and submit the subscribe form on the custom domain with Django's CSRF checks on.

Frequently asked questions

How do I read the current domain in a Django view?

Call request.get_host() and drop the port with .split(":")[0]. Behind SaaS Custom Domains the customer's domain arrives in X-Served-For; once a middleware has checked the X-SaaS-Domains-Auth-Token header and copied it into HTTP_HOST, get_host() returns it too.

Why does my custom domain return 400 Bad Request in Django?

The domain isn't in ALLOWED_HOSTS, so request.get_host() raises DisallowedHost and the log says "Invalid HTTP_HOST header". Set ALLOWED_HOSTS = ["*"] and let a middleware return 404 for hosts that aren't yours or an active custom domain.

Is ALLOWED_HOSTS = ["*"] safe?

Yes, if a middleware does the host check before any view runs. It should sit right after SecurityMiddleware, look the host up in APP_HOSTS and your CustomDomain table, and return 404 when it isn't found. Exempt the health check URL, and build links in emails from a setting rather than the request.

Does django-tenants support custom domains?

Yes. Add the domain as a Domain row for the tenant with is_primary=False, after its DNS checks out, and TenantMainMiddleware routes it to that tenant's schema. Behind a proxy that sends the domain in a header, put the token middleware above TenantMainMiddleware so get_host() already returns the customer's domain.

Why do forms fail with "Origin checking failed" on custom domains?

Django compares the browser's Origin with request.get_host() and the request scheme. Behind a proxy that keeps your own hostname in Host, they don't match. After verifying the proxy's token, set request.META["HTTP_HOST"] to the customer's domain, keep USE_X_FORWARDED_HOST off, and set SECURE_PROXY_SSL_HEADER.

Can customers use a root domain?

Yes. An apex domain can't hold a CNAME, so customers add A records for the proxy's two static IPs, 75.2.96.173 and 99.83.186.151. Providers with ALIAS records or CNAME flattening let them point the apex at in.saascustomdomains.com instead.

Next steps

That's the whole Django setup: customer DNS pointing at the proxy, a domain model, middleware that finds the tenant, a token check on proxied requests, and forms that work on every customer's domain. All of it runs in the custom-domains-django example, with tests. For running custom domains at scale, see our guide to custom domains for SaaS. There are versions of this walkthrough for Ruby on Rails and Node.js.

Building an API with FastAPI instead? The FastAPI guide covers the same setup with ASGI middleware and dependencies.

Questions? Email support or use the chat on saascustomdomains.com. Sign up for the 7-day trial, or pick a 15-minute slot if you'd like us to walk you through it.

More Articles

Cover Image for 45-Day Certificates: What Changes for SaaS Custom Domains
Drago Crnjac

45-Day Certificates: What Changes for SaaS Custom Domains

Let's Encrypt cuts certificates to 64 days on February 10, 2027. For platforms that issue certificates for customer domains, the real change is how little time you get to fix a customer's broken DNS.

Read more
Cover Image for Custom Domain SSL: 12 Reasons Not to Build It Yourself
Drago Crnjac

Custom Domain SSL: 12 Reasons Not to Build It Yourself

The first certificate takes an afternoon. Then come 45-day renewals, rate limits, static IPs for root domains, scanners, abuse, and a CDN you never meant to build.

Read more
Cover Image for How to Add Custom Domains to a Multi-Tenant Spring Boot App
Drago Crnjac

How to Add Custom Domains to a Multi-Tenant Spring Boot App

How to add custom domains to a Spring Boot app: finding the customer from the hostname, redirects and forms that stay on their domain, and HTTPS.

Read more