Custom Domains With Automatic HTTPS for Django Tenants
Let SaaS customers point their own domains at a multi-tenant Django app with automatic HTTPS: Caddy on-demand TLS with an ask endpoint in Django, domain verification, tenant lookup by Host, and protecting against abuse and rate limits.
Problem statement
SaaS customers want their portal at shop.customer.com rather than customer.yourapp.com. Each custom domain needs a valid certificate, and you cannot know the list in advance: customers add and remove domains at any time. Issuing certificates by hand does not scale. Caddy's on-demand TLS obtains a certificate the first time a TLS handshake arrives for a new hostname, after asking your Django app whether that hostname belongs to a real tenant. Django then routes the request to the right tenant by its Host header.
- Customers point their domain at your service with a CNAME (or A record) to a stable hostname such as
edge.yourapp.com. - Store each tenant's custom domain with a verified flag in Django.
- Add an internal "ask" endpoint, for example
/internal/tls-allowed?domain=shop.customer.com, that returns 200 only for verified domains. - Configure Caddy with
on_demand_tls { ask http://127.0.0.1:8000/internal/tls-allowed }andtls { on_demand }on a catch-all site. - In Django, resolve the tenant from
request.get_host()and validate hosts against the tenant table instead of a staticALLOWED_HOSTSlist. - Persist Caddy's data directory; it holds every issued certificate.
Step-by-step solution
1. Model and verify custom domains
class TenantDomain(models.Model):
tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE, related_name="domains")
domain = models.CharField(max_length=253, unique=True)
verified_at = models.DateTimeField(null=True)
Verify ownership before enabling a domain, for example by checking that shop.customer.com resolves (CNAME) to edge.yourapp.com, or that a TXT record with a token exists. This stops someone from adding a domain they do not control.
2. The ask endpoint
# tenants/views.py
from django.http import HttpResponse
from django.views.decorators.http import require_GET
@require_GET
def tls_allowed(request):
domain = request.GET.get("domain", "").lower().rstrip(".")
ok = TenantDomain.objects.filter(domain=domain, verified_at__isnull=False).exists()
return HttpResponse(status=200 if ok else 404)
Expose it only on localhost or the internal network (a separate URLconf served on an internal port, or a check on REMOTE_ADDR). Caddy calls it before every new certificate request, so it must be fast; a cached lookup is ideal.
3. Configure Caddy
{
on_demand_tls {
ask http://127.0.0.1:8000/internal/tls-allowed
}
email ops@yourapp.com
}
yourapp.com, *.yourapp.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy 127.0.0.1:8000
}
https:// {
tls {
on_demand
}
reverse_proxy 127.0.0.1:8000
}
The first block serves your own domains with a wildcard (DNS challenge). The catch-all https:// site uses on-demand TLS for any other hostname that passes the ask check. Caddy passes the original Host to Django.
4. Route tenants and validate hosts in Django
class TenantFromHostMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
host = request.get_host().split(":")[0].lower()
request.tenant = resolve_tenant(host) # subdomain or verified custom domain, cached
if request.tenant is None:
raise Http404("Unknown site")
return self.get_response(request)
ALLOWED_HOSTS cannot list unknown future domains. Set ALLOWED_HOSTS = ["*"] only together with middleware placed first that rejects any host not in your tenant table, so Host header validation still happens, just against the database. Also add each custom domain's origin to CSRF trust dynamically (for example a custom CSRF origin check) or keep forms on the tenant's own host.
5. Operations
- Persist Caddy's
/data(certificates and ACME account) on a volume and back it up. - Watch Let's Encrypt limits: on-demand issuance is per domain, so a burst of new tenants is fine, but failed validations count toward limits; the ask endpoint keeps them rare.
- When a tenant removes a domain, mark it unverified; Caddy will not renew it once the ask returns 404.
Verification check: add a test domain with a CNAME to edge.yourapp.com, mark it verified, and load it over HTTPS: the first request takes a few seconds while the certificate is issued, later ones are instant; an unverified domain fails the TLS handshake and the ask endpoint log shows a 404.
Explanation
On-demand TLS defers certificate issuance to the moment a client connects with an SNI hostname Caddy has no certificate for. To prevent abuse (anyone can point a domain at your IP), Caddy consults an external authorisation URL first. Django is the natural authority because it already knows which tenants exist and which domains they have verified. After issuance, Caddy stores and renews certificates automatically, and Django routes each request to a tenant by host, keeping custom domains a data concern rather than a configuration change.
Edge cases / notes
- Apex domains (
customer.com) cannot use CNAMEs at many DNS providers; give customers an IP for A records or recommendwww. - Several Caddy instances need shared storage for certificates (a shared filesystem, Redis, or a storage plugin) so they do not each issue.
- Cookies are per domain, so a user logging in on a custom domain gets a separate session from the platform domain.
- Nginx alternative. OpenResty with lua-resty-auto-ssl or a managed service (Cloudflare for SaaS, Approximated) can provide the same feature.
Next steps
- Basics of Caddy for Django: Deploy Django With Caddy and Automatic HTTPS.
- Tenant isolation patterns: Deploy Django Multi-Tenant Apps With django-tenants.
FAQ
How do SaaS apps give customers custom domains with HTTPS?
With on-demand certificate issuance, such as Caddy's on-demand TLS, gated by a check that the domain belongs to a verified tenant.
Why does Caddy need an ask endpoint?
To refuse certificates for domains you do not serve, preventing abuse and Let's Encrypt rate-limit exhaustion.
How does Django know which tenant a custom domain belongs to?
Middleware looks up the request's host in a table of verified tenant domains.
Can I keep ALLOWED_HOSTS with custom domains?
You must validate hosts dynamically, for example with middleware checking the tenant table, since the list changes at runtime.