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
- From a DNS record to a Django request
- Save customer domains in a model
- Resolve the tenant from the hostname
- Fix "Invalid HTTP_HOST header" errors
- Why every custom domain needs its own certificate
- Connect Django to SaaS Custom Domains, including the "Origin checking failed" fix
- Test custom domains on localhost
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 --buildOpen 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 | |
|---|---|
| Django | 6.1.2 |
| Python | 3.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.comis a subdomain, so it gets a CNAME toin.saascustomdomains.com. For customers who shouldn't see our hostname at all, you can offer a white-label target of yours, such asin.statuspage.com.- An apex domain such as
intercomstatus.comhas 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.151The 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:
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=Trueonhost. 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, soStatus.Intercom.com.fails the regex because of the trailing dot, even though it's the same host asstatus.intercom.com. The fix is a helper that lowercases, strips the dot and converts international names to punycode, called beforefull_clean():
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 itnot_one_of_oursblocks your own domain and its subdomains, so nobody can registeradmin.statuspage.comas their "custom" domain.provider_uuidkeeps the domain's UUID at SaaS Custom Domains, for checking and deleting it later.https_readyturns 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:
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:
# 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)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:
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:
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.urlconfis 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. Onapp.statuspage.com,/subscribeis 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_fromis 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:
CustomDomainMiddlewaresits directly afterSecurityMiddleware(with onlyProxyTokenMiddlewarein between), ahead of anything that builds a page or sends email.- It answers 404 for every host that isn't in
APP_HOSTSor 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:
- Get an API token (about a minute)
- Register your Django app as an upstream (about a minute)
- Register domains when customers add them (around 5 minutes)
- Have the customer update their DNS (about a minute, on their side)
- 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 account settings page shows your 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:
# 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:
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 domainHow it behaves:
full_clean()raises aValidationErrorfor an invalid host, one of your own domains, or a domain another company already has. Show its messages next to the form.instructions_recipientis sent only when there's an address, sincedns_contact_emailis 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
pendingforever 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.

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:
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.): /subscribeCSRF_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:
ALLOWED_HOSTS = ["*"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")ALLOWED_HOSTS = ["*"], becauseget_host()now returns customer domains.CustomDomainMiddlewarestill 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 anhttp://origin, and the form fails with the same message.- Leave
USE_X_FORWARDED_HOSTat its default,False. With it on, Django readsX-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:
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:
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:
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.


