Framework guides
Django 6.1 custom domains guide
Versions
If your Django app gives each customer a portal or a set of pages, they may want to use their own domain to reach them. Custom domain support makes that possible while your Django app continues to serve the content and manage access.
We’ll work through the feature a step at a time, starting with two local addresses that show different customers’ pages. From there, you’ll see how to save domains, connect DNS and HTTPS, and serve the right customer. We’ll explain the DNS setup when we get to it.
The examples build on an existing Django app with customer accounts and a login. Use your own models and database, and adapt the small examples to the pages you already have.
In this guide
1. Match a hostname to a customer locally
Every HTTP request includes a Host header such as northwind.test. Django exposes it through request.get_host(). Start with a small development map so you can prove that one process serves different customer content.
The examples call a customer’s account a Tenant. Use your existing account, workspace, or organization model wherever you see that name.
# settings.py — development only
ALLOWED_HOSTS = ["northwind.test", "contoso.test", "unknown.test"]
# domains/views.py
from django.http import Http404
from django.shortcuts import get_object_or_404, render
from .models import Tenant
LOCAL_HOSTS = {
"northwind.test": "northwind",
"contoso.test": "contoso",
}
def customer_page(request):
hostname = request.get_host().split(":", 1)[0].rstrip(".").lower()
slug = LOCAL_HOSTS.get(hostname)
if slug is None:
raise Http404("Unknown customer domain")
tenant = get_object_or_404(Tenant, slug=slug)
return render(request, "domains/portal.html", {"tenant": tenant})
Route / to this view in development. Replace domains/portal.html with your existing customer-page template. This map selects public content only. It must never decide which account a signed-in user may manage.
curl --resolve northwind.test:8000:127.0.0.1 http://northwind.test:8000/
curl --resolve contoso.test:8000:127.0.0.1 http://contoso.test:8000/
curl -i --resolve unknown.test:8000:127.0.0.1 http://unknown.test:8000/
Checkpoint: the first two requests show different customers, and the unknown hostname returns 404.
2. Save each customer's domains
Move the temporary map into a model owned by your app. A globally unique normalized hostname prevents two customers from claiming the same name in your database. The local status keeps pending and removing domains out of public routing.
# domains/models.py
class CustomDomain(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "Pending"
ACTIVE = "active", "Active"
REMOVING = "removing", "Removing"
tenant = models.ForeignKey(
Tenant, on_delete=models.CASCADE, related_name="domains"
)
hostname = models.CharField(max_length=253, unique=True)
status = models.CharField(
max_length=16, choices=Status, default=Status.PENDING
)
Accept a hostname, not a URL. Trim spaces and the final DNS dot, lowercase it, convert Unicode labels with IDNA, then reject schemes, ports, paths, wildcards, literal IP addresses, and invalid DNS labels. Keep that normalization in one function and use it for writes and lookups.
The add view should get the customer from your existing signed-in membership, not from a form field. Django's CSRF middleware protects the POST when the template includes {% csrf_token %}.
# domains/views.py
@login_required
@require_POST
def add_domain(request):
tenant = tenant_for_member(request.user) # your membership lookup
hostname = normalize_hostname(request.POST["hostname"])
try:
with transaction.atomic():
CustomDomain.objects.create(
tenant=tenant,
hostname=hostname,
)
except IntegrityError:
messages.error(request, "That hostname is already reserved.")
return redirect("dashboard")
Here, tenant_for_member is an explicit connection to your existing authorization code. It must verify membership on the server. SQLite is enough for the companion example; use your app's current database in production.
Checkpoint: a signed-in customer can reserve a normalized hostname, while duplicates and requests for another customer are rejected.
3. Connect DNS and HTTPS
A reverse proxy is a server in front of Django that receives requests and forwards them to your app. Your hosting platform may provide one, or you can run one yourself. Register the customer hostname there first, then show the customer the DNS record that points to it.
For a subdomain such as portal.example.com, a CNAME can point to a hostname owned by your receiving service.
An apex, or root domain, is the shorter example.com. Offer a stable IPv4 address and an A record for apex domains because many DNS providers cannot place a normal CNAME there and do not support CNAME flattening. You can offer an AAAA record as an additional IPv6 route when your service supports it.
- Add the exact customer hostname to the server, load balancer, or hosting platform that will receive it.
- Show the matching CNAME for subdomains or stable IPv4 A record for apex domains.
- Have the customer copy that record into the DNS provider that manages their domain.
- Issue a valid HTTPS certificate for the hostname and arrange automatic renewal.
- Forward the original
Hostheader to Django so the app can select the customer.
You’ll probably want to check that DNS points directly or indirectly to the service receiving traffic for your app. Tools and managed services can make DNS setup and HTTPS much easier; see the bonus section below for one option.
DNS routes the request to your receiving service. HTTPS is negotiated before Django sees the request, so certificate issuance and renewal belong at that service. Mark the saved domain active only after the hostname is connected there and HTTPS is ready.
Checkpoint: the hostname reaches your receiving service over HTTPS and arrives at Django with its original Host.
4. Serve the customer's pages
Replace the development map with a database lookup. The middleware below accepts your normal app hostname or an active customer domain. Unknown, pending, and removing names return 404 before a view runs.
# settings.py — after your existing MIDDLEWARE setting
APP_PRIMARY_HOSTS = {"app.example"} # your usual app hostname
ALLOWED_HOSTS = ["*"]
USE_X_FORWARDED_HOST = False
MIDDLEWARE = ["domains.middleware.TenantHostMiddleware", *MIDDLEWARE]
# domains/middleware.py
from django.conf import settings
from django.http import Http404
from .models import CustomDomain
class TenantHostMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
hostname = request.get_host().partition(":")[0].rstrip(".").lower()
request.custom_domain = None
if hostname not in settings.APP_PRIMARY_HOSTS:
request.custom_domain = (
CustomDomain.objects.select_related("tenant")
.filter(
hostname=hostname,
status=CustomDomain.Status.ACTIVE,
)
.first()
)
if request.custom_domain is None or request.path != "/":
raise Http404("Unknown customer domain")
return self.get_response(request)
# domains/views.py — replace the temporary LOCAL_HOSTS view
def customer_page(request):
if request.custom_domain is None:
return redirect("dashboard")
return render(
request,
"domains/portal.html",
{"tenant": request.custom_domain.tenant},
)
Keep customer management on your normal app hostname. With ALLOWED_HOSTS = ["*"], this middleware checks which domains may reach your app, so keep it enabled. The example exposes only / on customer domains; add other customer-facing paths as needed.
Read the direct Host by default. Trust a forwarded hostname only when a known proxy overwrites it and direct access is blocked or authenticated. A client-supplied header must never grant account access.
Keep session cookies host-only. Build absolute links from a trusted canonical app URL when they should return to the dashboard. If you cache public pages, include the normalized hostname or customer ID in the key; otherwise one customer's page can be served to another.
Checkpoint: active domains show the right customer page, while the app hostname still serves management and every other hostname returns 404.
5. Remove a domain safely
Stop Django routing before you ask the receiving infrastructure to detach the hostname and certificate. Keep the unique reservation while that work is pending, including after a timeout or unclear error, so another customer cannot claim the name too early.
# domains/removal.py
from django.db import transaction
from .models import CustomDomain
@transaction.atomic
def start_removal(domain):
identity = {
"pk": domain.pk,
"tenant_id": domain.tenant_id,
"hostname": domain.hostname,
}
current = (
CustomDomain.objects.select_for_update().filter(**identity).first()
)
if current is None or current.status not in {
CustomDomain.Status.PENDING,
CustomDomain.Status.ACTIVE,
CustomDomain.Status.REMOVING,
}:
return None
if current.status != CustomDomain.Status.REMOVING:
current.status = CustomDomain.Status.REMOVING
current.save(update_fields=["status"])
return identity
def finish_removal(identity):
return CustomDomain.objects.filter(
**identity, status=CustomDomain.Status.REMOVING
).delete()
Pass the captured identity to your hosting cleanup job or adapter. Retry that same removal when the result is unclear. Call finish_removal only after the receiving system confirms that the hostname and certificate are gone.
A delayed status check should update only the same customer and hostname while the saved state still matches. That prevents an older check from turning routing back on after removal started.
Checkpoint: removal immediately returns 404, retries keep the reservation, and only confirmed infrastructure cleanup releases the hostname.
Bonus: let Approximated handle HTTPS and forwarding
The five steps above work with any hosting setup. Approximated can handle accepting each customer hostname, issuing and renewing its certificate, forwarding traffic to your app server, and returning the DNS instructions to show in your dashboard.
Your app still owns customer authorization, hostname reservation, content lookup, and the decision to activate. In the API, a virtual host is the mapping from the customer's hostname to your reachable app-server hostname.
Read APX_API_KEY and APX_ORIGIN_HOST from the server environment. The origin host is the reachable hostname of your Django app. After the hostname is reserved, send POST /api/vhosts with keep_host: true, then attach the result only when it contains a positive numeric ID and the same hostname you requested.
# settings.py
import os
APX_API_KEY = os.environ["APX_API_KEY"]
APX_ORIGIN_HOST = os.environ["APX_ORIGIN_HOST"]
# domains/services.py
import json
from urllib.request import Request, urlopen
from django.conf import settings
from .models import CustomDomain
def connect_with_approximated(domain):
payload = {
"incoming_address": domain.hostname,
"target_address": settings.APX_ORIGIN_HOST,
"target_ports": "443",
"keep_host": True,
}
request = Request(
"https://cloud.approximated.app/api/vhosts",
data=json.dumps(payload).encode(),
method="POST",
headers={
"api-key": settings.APX_API_KEY,
"content-type": "application/json",
},
)
with urlopen(request, timeout=10) as response:
envelope = json.load(response)
remote = envelope.get("data") if isinstance(envelope, dict) else None
if not isinstance(remote, dict):
raise ValueError("Invalid response envelope")
if type(remote.get("id")) is not int or remote["id"] <= 0:
raise ValueError("Missing virtual-host id")
if remote.get("incoming_address") != domain.hostname:
raise ValueError("Virtual-host identity mismatch")
return CustomDomain.objects.filter(
pk=domain.pk,
tenant_id=domain.tenant_id,
hostname=domain.hostname,
status=CustomDomain.Status.PENDING,
).update(approximated_id=remote["id"])
Add approximated_id as an optional field for this adapter; the provider-independent flow does not need it. If creation times out or conflicts, keep the customer's reservation but do not search for, adopt, or delete a remote resource by hostname.
Treat missing status as pending, missing status_message as empty, and missing monitor fields as unknown. Reject a present status field unless it is text, and reject a present monitor unless it is Boolean or null. Show the customer these signals in plain language:
user_message: the exact DNS instructions, displayed as plain textis_resolving: the hostname returns an HTTP response somewhereapx_hit: traffic reaches Approximatedhas_ssl: HTTPS is ready
Approximated checks DNS and HTTPS automatically. Read the latest saved results with GET /api/vhosts/{id}, using the confirmed ID you stored. This does not start a fresh check.
Activate only when that response still has the same ID and hostname, apx_hit is true, and has_ssl is true. Keep is_resolving as a diagnostic because an HTTP response may come from somewhere other than Approximated.
For removal, mark the row removing before DELETE /api/vhosts/{id}. Keep it reserved after an error or accepted deletion, retry the same stored ID, and release only after GET /api/vhosts/{id} returns 404. A delayed check should update only the same customer, hostname, provider ID, and expected state.
Checkpoint: the dashboard shows the returned DNS instructions, and the confirmed mapping becomes active only after Approximated reports connection and HTTPS ready.
Run the complete Django example
The Django custom-domains example includes two tenants, SQLite storage, Django sessions and CSRF, a local provider stub, readiness checks, and confirmed removal.
git clone https://github.com/Approximated-Inc/django-custom-domains-example.git
cd django-custom-domains-example
python3.14 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python manage.py migrate
.venv/bin/python manage.py seed_demo
.venv/bin/python manage.py runserver 127.0.0.1:8083