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.

On-demand TLS for tenant domainsCustomer CNAMEto your edge hostFirst handshakeunknown hostnameCaddy asks Djangois this a tenant?Cert issuedthen cached, renewedCaddy only requests a certificate after Django confirms the domain belongs to a verified tenant.
  1. Customers point their domain at your service with a CNAME (or A record) to a stable hostname such as edge.yourapp.com.
  2. Store each tenant's custom domain with a verified flag in Django.
  3. Add an internal "ask" endpoint, for example /internal/tls-allowed?domain=shop.customer.com, that returns 200 only for verified domains.
  4. Configure Caddy with on_demand_tls { ask http://127.0.0.1:8000/internal/tls-allowed } and tls { on_demand } on a catch-all site.
  5. In Django, resolve the tenant from request.get_host() and validate hosts against the tenant table instead of a static ALLOWED_HOSTS list.
  6. 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.

first visit to shop.customer.com1TLS handshake for unknown nameno certificate yet2Caddy calls the ask endpointGET ?domain=shop.customer.com3Django: verified tenant? 200404 stops issuance4ACME issuance via HTTP or TLS-ALPNa few seconds, once5Request routed to tenantcert cached and renewedThe ask check is what stops strangers from making you issue certificatesdjango-deployment.com
Without an ask endpoint, anyone could point domains at you and exhaust rate limits.

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.
custom domains for tenantsOwnership verified before enablingCNAME or TXT checkFast, internal-only ask endpoint200 only for verified domainsHost validated against tenant tablemiddleware runs firstCaddy data directory persistedcertificates survive restartsOn-demand TLS without askabuse and rate limitsVerification plus the ask endpoint make on-demand TLS safedjango-deployment.com
Log every ask decision so support can see why a domain has no certificate.

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 recommend www.
  • 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

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.

Keep going