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, your customer points a CNAME (or A records for a root domain) at your servers, you save the domain against their account, and a middleware looks up the tenant from request.get_host(). After that, each domain needs its own HTTPS certificate, and that's the part that takes real work.

The Django part is small: a model, a middleware and a second URLconf. This guide walks through it for Django 5.2 LTS and 6.x, and covers what trips up Django apps in production: "Invalid HTTP_HOST header" errors, ALLOWED_HOSTS, and forms that fail with "Origin checking failed".

In this guide:

How custom domains reach your Django app

The example is the same one our Rails guide uses: a status page product at app.statuspage.com, on a server at 192.0.2.1. Intercom, one of its customers, wants its status page at status.intercom.com.

To make that work, Intercom adds a DNS record:

  • status.intercom.com is a subdomain, so it gets a CNAME. Point it at a hostname you control and use only for this, like in.statuspage.com, and you can move servers later without bothering customers.
  • A root domain like intercomstatus.com can't hold a CNAME. It needs an A record with your server's IP, or an ALIAS record if the DNS provider has them.
; Subdomain
status.intercom.com.   CNAME  in.statuspage.com.

; Root domain
intercomstatus.com.    A      192.0.2.1

Once customers have your IP in their A records, changing it means emailing every one of them, so treat it as permanent. The DNS section of our custom domains guide has the details.

From then on, a request for status.intercom.com reaches Django with that hostname in the Host header.

Store your customers' domains

A CustomDomain model maps each domain to a tenant, with a status so you only serve domains that are actually pointed at you. In this example, a Company is the tenant:

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
    )
    created_at = models.DateTimeField(auto_now_add=True)

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

A few notes:

  • unique=True matters. Without it, two companies can claim the same domain, and whichever row comes back first gets the other's traffic.
  • Normalize first. Status.Intercom.com. with a capital letter and a trailing dot is still status.intercom.com. Normalize user input, then call full_clean() so the validators run (add_custom_domain below does both). For international domain names, convert to punycode first with host.encode("idna").decode().
  • Block your own domains, or a customer can claim admin.statuspage.com.

Check that the DNS points at you

New domains start as pending and only become active once their DNS points at you. Python's standard library can't look up CNAME records, so use dnspython:

domains/dns.py
import dns.exception
import dns.resolver

CNAME_TARGET = "in.statuspage.com"
OUR_IPS = {"192.0.2.1"}


def records(host, rtype):
    try:
        answer = dns.resolver.resolve(host, rtype)
    except dns.exception.DNSException:
        return set()
    return {r.to_text().rstrip(".").lower() for r in answer}


def points_to_us(host):
    if CNAME_TARGET in records(host, "CNAME"):
        return True
    return bool(records(host, "A") & OUR_IPS)

A periodic task (Celery beat, cron, whatever you already use) can run this every few minutes for pending domains and once a day for active ones. Customers do change their DNS later, usually without telling you.

Resolve the tenant from the hostname

Every request includes a Host header with the domain the visitor typed, and Django gives it to you as request.get_host(). A middleware can look it up once per request, attach the company, and switch to a separate URLconf for customer-facing pages:

domains/utils.py
def host_from(request):
    return request.get_host().split(":")[0].lower()
domains/middleware.py
from django.conf import settings
from django.http import Http404

from .models import CustomDomain
from .utils import host_from


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

    def __call__(self, request):
        request.company = None
        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"),
]
status/views.py
from django.shortcuts import render


def status_page(request):
    data = request.company.fetch_status_data()
    return render(request, "status/page.html", {"data": data})

And in your settings:

statuspage/settings.py
APP_DOMAIN = "statuspage.com"
APP_HOSTS = ["app.statuspage.com", "localhost"]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "domains.middleware.CustomDomainMiddleware",
    # ...the rest of your middleware
]

Here's what that gets you:

  • request.urlconf is Django's built-in way to serve a different set of URLs per request. Custom domains only see the status page URLs, not your admin or your app's dashboard.
  • Unknown or pending domains get a 404. The page shouldn't render for someone who pointed a random domain at your IP.
  • host_from is the one place that reads the hostname. If you put a proxy in front later, it's the only function you change.

Using django-tenants?

django-tenants already resolves the tenant (and its Postgres schema) from the hostname, using its Domain model. Supporting a custom domain is one row:

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

Its TenantMainMiddleware reads request.get_host(), so the rest of this guide still applies: ALLOWED_HOSTS, HTTPS, and the proxy header below.

Fix "Invalid HTTP_HOST header" errors

The first time a custom domain reaches your production app, you'll probably get a 400 Bad Request and this in your logs:

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

ALLOWED_HOSTS protects against Host header attacks, such as password reset emails with links to an attacker's domain. It only takes a fixed list (with .example.com for subdomains), so you can't list thousands of customer domains.

The common fix is ALLOWED_HOSTS = ["*"], with the middleware above doing the checking instead. That's safe only if:

  • CustomDomainMiddleware comes right after SecurityMiddleware, before anything that renders a page or sends an email.
  • It returns a 404 for any host that isn't yours or an active custom domain, which the code above does.

If you run Django behind SaaS Custom Domains, you don't need "*": the Host header stays your own hostname. More on that below.

Add HTTPS for every custom domain

Pointing DNS at you isn't enough on its own. Browsers expect HTTPS, and until you have a certificate for status.intercom.com, visitors get a full-page security warning instead of the status page.

Every domain needs its own certificate. Let's Encrypt issues them for free; the work is in requesting one for each new domain, renewing it before it expires, and terminating TLS in front of Django. That's the job of a TLS-terminating reverse proxy. There are two realistic ways to run one.

Option 1: Caddy with on-demand TLS

Caddy can issue a certificate mid-handshake for a hostname it has never seen, a feature called on-demand TLS. Turn it on without any check and a stranger can point thousands of domains at you and use up your Let's Encrypt rate limits. So Caddy calls a URL on your app first and only continues if you answer 200:

Caddyfile
{
	on_demand_tls {
		ask http://localhost:8000/caddy/ask
	}
}

https:// {
	tls {
		on_demand
	}
	reverse_proxy localhost:8000
}

The ask view returns 200 for active domains and 404 for everything else:

domains/views.py
# urls.py: path("caddy/ask", views.caddy_ask)
from django.http import HttpResponse

from .models import CustomDomain


def caddy_ask(request):
    host = request.GET.get("domain", "").lower()
    known = CustomDomain.objects.filter(
        host=host, status=CustomDomain.Status.ACTIVE
    ).exists()
    return HttpResponse(status=200 if known else 404)

Caddy calls it on localhost, which is in APP_HOSTS, so the middleware lets it through to your main URLconf. Caddy also passes the original Host header to Django, so the middleware above works as is.

Since Caddy terminates TLS and talks plain HTTP to Django, tell Django to trust the X-Forwarded-Proto header Caddy sets. Otherwise request.is_secure() is False and CSRF checks on forms fail:

statuspage/settings.py
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

This setup covers a single server. With more than one, you also need certificates stored where every server can read them, a backup certificate authority, alerts for failed renewals, and a way to deal with abuse. Our custom domains guide covers those.

Option 2: a managed proxy

The other option is to let someone else run the proxy. That's what our product does, and the rest of this guide uses it: SaaS Custom Domains starts at $29/month for 100 domains.

Set it up with SaaS Custom Domains

With SaaS Custom Domains, your customers' domains point at our proxy instead of your server. We handle certificates (Let's Encrypt and ZeroSSL, renewals included), run the proxy in 119 locations on AWS, and pass each request on to Django with the customer's domain in a header. There's an API and webhooks for managing domains from your app, two static IPs for root domains, and optional emails that tell your customers which DNS records to add.

Pricing is $0.29 per domain per month, with a 100-domain minimum ($29/month) and discounts from 1,000 domains. The 7-day free trial needs a card, and you can cancel any time.

Here are the steps:

  1. Create an API token (1 minute)
  2. Create an upstream for your Django app (1 minute)
  3. Add custom domains from your app (5 minutes)
  4. Point the customer's DNS at us (1 minute)
  5. Read the custom domain in Django and verify the proxy's token (5–15 minutes)

1. Create an API token

Generate an API token under API tokens in your account settings.

Screenshot showing the API tokens settings in the UI.

Your account UUID is on the account settings page:

Screenshot of the account settings page in the UI

Add them to your environment as SCD_API_TOKEN and SCD_ACCOUNT_UUID. Anyone with the token can manage your domains, so keep it out of your repo.

2. Create an upstream

The upstream tells us where to send traffic: your Django app. This client wraps the API calls you'll need, using requests:

domains/saas_domains.py
import os

import requests

BASE = "https://app.saascustomdomains.com/api/v1/accounts"


class SaasDomains:
    def __init__(self):
        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):
        return self._request("DELETE", f"{self._domains()}/{uuid}")

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

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

You only create the upstream once, so do it from python manage.py shell:

>>> 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...',
 ...}

The response includes two values to keep: put the uuid in SCD_UPSTREAM_UUID, and the auth_token in PROXY_AUTH_TOKEN for step 5. Upstreams can also be created in the dashboard; the upstreams API docs describe every option.

3. Add custom domains from your app

Add a field for the UUID we return, so you can check and remove the domain later:

domains/models.py
class CustomDomain(models.Model):
    # ...the fields from before
    provider_uuid = models.CharField(
        max_length=36, unique=True, null=True, blank=True
    )

Then register the domain with us when a customer adds it:

domains/services.py
from .models import CustomDomain
from .saas_domains import SaasDomains


def add_custom_domain(company, host):
    domain = CustomDomain(company=company, host=host.strip().lower())
    domain.full_clean()
    domain.save()

    res = SaasDomains().create_domain(
        domain.host,
        # Whoever manages their DNS gets step-by-step instructions
        instructions_recipient=company.dns_contact_email,
    )
    domain.provider_uuid = res["uuid"]
    domain.save(update_fields=["provider_uuid"])
    return domain

Leave out instructions_recipient if you'd rather send DNS instructions yourself. With it, we email the records to that address, which is handy because the person adding a domain in your app is often not the person with access to DNS. The call can also run in a background task if you don't want it inside the web request. All fields are in the custom domains API docs.

4. Point the customer's DNS at us

Your customer adds one of these:

; Subdomain
status.intercom.com.   CNAME  in.saascustomdomains.com.

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

Existing A records for the domain need to be removed, or part of the traffic will keep going to wherever they pointed before. You can also offer your own CNAME target, such as in.statuspage.com, so our hostname never shows up in your instructions. Changes usually take effect within a few minutes.

Cloudflare needs one extra step. The record must be DNS only (grey cloud). A proxied record (orange cloud) resolves to Cloudflare's IPs, which breaks both the DNS check and certificate issuance.

DNS records should have the DNS Only option enabled.

Some customers rely on Cloudflare's proxy and won't turn it off. For them, call create_domain with challenge_type="dns01" and ask them to add the extra _acme-challenge CNAME the API returns, so we can get the certificate through DNS validation.

5. Read the custom domain in Django

When a request comes through our proxy, its Host header holds your app's hostname, app.statuspage.com. That keeps your hosting setup and ALLOWED_HOSTS happy without changes. The customer's domain is in a separate header, X-Served-For.

Because all the lookups go through host_from, it's the only function you need to change:

domains/utils.py
def host_from(request):
    host = request.headers.get("X-Served-For") or request.get_host()
    return host.split(":")[0].lower()

On django-tenants, override its hostname_from_request the same way:

domains/middleware.py
from django_tenants.middleware.main import TenantMainMiddleware
from django_tenants.utils import remove_www

from .utils import host_from


class ProxyTenantMiddleware(TenantMainMiddleware):
    @staticmethod
    def hostname_from_request(request):
        return remove_www(host_from(request))

Check that the request came through the proxy

Now think about who else can set that header. If someone reaches Django directly, bypassing the proxy, they can send X-Served-For: status.intercom.com themselves and get Intercom's pages back.

To stop that, our proxy signs each request with your upstream's auth token, in the X-SaaS-Domains-Auth-Token header (docs). This middleware rejects any request that names a custom domain but doesn't carry the token, using hmac.compare_digest so the comparison takes the same time whatever the input:

domains/middleware.py
import hmac
import os

from django.http import HttpResponseForbidden


class ProxyTokenMiddleware:
    # List it before CustomDomainMiddleware in settings.MIDDLEWARE

    def __init__(self, get_response):
        self.get_response = get_response
        self.expected = os.environ["PROXY_AUTH_TOKEN"].encode()

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

Load the token from the environment, never from code, and generate a new one in the dashboard if you think it has leaked.

Forms and CSRF

For read-only pages, you're done. If visitors submit forms on the custom domain, Django's CSRF check gets in the way. It compares the browser's Origin header with request.get_host(), which behind the proxy is your own hostname, and rejects the form with a 403:

CSRF verification failed. Request aborted.
Origin checking failed - https://status.intercom.com does not match
any trusted origins.

CSRF_TRUSTED_ORIGINS doesn't help here: like ALLOWED_HOSTS, it's a fixed list. The fix is to have Django see the customer's domain:

  1. Ask support to send the custom domain in X-Forwarded-Host for your account.
  2. Tell Django to use it:
statuspage/settings.py
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# get_host() now returns the customer's domain, so let the
# middleware do the host checks (see "Invalid HTTP_HOST header")
ALLOWED_HOSTS = ["*"]

Now request.get_host(), request.build_absolute_uri() and the CSRF check all use the customer's domain.

USE_X_FORWARDED_HOST makes Django trust a header any client can send. The middleware still only lets through your own hosts and active custom domains, but build links in emails, like password resets for your own app, from a fixed setting rather than request.get_host().

Leave SESSION_COOKIE_DOMAIN and CSRF_COOKIE_DOMAIN unset. Django's default cookies are host-only, which is what you want on a domain you don't own. If you've set them to .statuspage.com to share sessions across subdomains, browsers will reject those cookies on custom domains.

Show DNS status and react to webhooks

"Is my domain working yet?" is the question you'll hear most. A "Check DNS" button that triggers a re-check answers it:

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"

For updates without polling, set up a webhook in the dashboard. We add the webhook's security token to every delivery as a token query parameter; check it before you act on anything in the payload:

domains/views.py
# urls.py: path("webhooks/saas-domains", views.saas_domains_webhook)
import hmac
import json
import os

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

from .models import CustomDomain

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):
    token = request.GET.get("token", "")
    expected = os.environ["SCD_WEBHOOK_TOKEN"]
    if not hmac.compare_digest(token.encode(), expected.encode()):
        return HttpResponse(status=401)

    payload = json.loads(request.body)
    status = STATUS_BY_EVENT.get(payload["event"])
    if status:
        CustomDomain.objects.filter(
            provider_uuid=payload["domain"]["uuid"]
        ).update(status=status)

    return HttpResponse(status=200)

Beyond the two DNS events, there are domain_created, domain_deleted and tls_certificate_issued, among others; the last one is a good trigger for marking a domain "HTTPS ready". The webhooks docs have the full list. Keep the handler fast and return a 2xx. We retry failed deliveries up to 10 times.

Remove a domain

Order matters when a domain goes away. Remove it from the proxy, then delete your row:

domains/services.py
def remove_custom_domain(domain):
    SaasDomains().delete_domain(domain.provider_uuid)
    domain.delete()

That way, an API error leaves your row in place, ready for another attempt. The reverse order risks a domain that still lives on the proxy after your app has forgotten about it. If you run Caddy yourself, there's nothing to call: delete the row, the ask view returns 404, and the certificate quietly expires.

Test custom domains locally

Local testing works without any real domains. Chrome, Firefox and curl send every *.localhost name to your own machine, and with DEBUG = True and an empty ALLOWED_HOSTS, Django accepts .localhost hosts:

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

Open http://intercom.localhost:8000 to see that company's status page, while http://localhost:8000 still shows your app.

To test the proxy path, add the headers by hand:

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

Leave out the token and Django should answer 403.

Frequently asked questions

How do I get the custom domain in a Django view?

Use request.get_host(), and strip the port with .split(":")[0]. Behind SaaS Custom Domains, read request.headers.get("X-Served-For") instead, after checking the X-SaaS-Domains-Auth-Token header.

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

The domain isn't in ALLOWED_HOSTS, so request.get_host() raises DisallowedHost. Either keep Host as your own hostname by running behind a proxy that sets it, or use ALLOWED_HOSTS = ["*"] with a middleware that returns 404 for unknown hosts.

Is ALLOWED_HOSTS = ["*"] safe?

Only if something else rejects unknown hosts before any view runs. A middleware right after SecurityMiddleware that looks the host up in your CustomDomain table, and returns 404 if it's missing, does the same job as ALLOWED_HOSTS.

Does django-tenants support custom domains?

Yes. Add the customer's domain as a Domain row for their tenant, and TenantMainMiddleware routes it to their schema. Behind a proxy that sends the domain in a header, override hostname_from_request.

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

Django compares the browser's Origin with request.get_host(). If a proxy rewrites Host to your own domain, they don't match. Have the proxy send the customer's domain in X-Forwarded-Host and set USE_X_FORWARDED_HOST = True.

Can customers use a root domain?

Yes. They add A records with your static IPs, since the root of a domain can't have a CNAME. Some DNS providers also offer ALIAS records or CNAME flattening, which behave like a CNAME at the root.

Next steps

You now have the full setup in Django: DNS pointing at you, a model for domains, middleware that finds the tenant, HTTPS, and a token check on proxied requests. For running it at scale, read our guide to custom domains for SaaS. The same walkthrough exists for Ruby on Rails and Node.js.

Stuck on something? Email support or open the chat on saascustomdomains.com. You can start a 7-day free trial or book a 15-minute demo.

More Articles

Cover Image for How to Add Custom Domains to a Ruby on Rails App
Drago Crnjac

How to Add Custom Domains to a Ruby on Rails App

How to add custom domains to a Ruby on Rails app: DNS records, storing domains, routing each request to the right tenant, and HTTPS for every domain.

Read more
Cover Image for Custom Domains for SaaS: How to Add Them (2026 Guide)
Drago Crnjac

Custom Domains for SaaS: How to Add Them (2026 Guide)

How to add custom domains to your SaaS app: DNS records, domain verification, HTTPS certificates, tenant routing and running it in production.

Read more
Cover Image for How to Add Custom Domains to a Node.js App
Drago Crnjac

How to Add Custom Domains to a Node.js App

How to add custom domains to a Node.js SaaS app with Express or Next.js: DNS records, storing domains, routing by hostname, and HTTPS for every domain.

Read more