If you run a SaaS, your customers eventually want their product to live on their domain — app.acme.com instead of acme.yourapp.com. It looks like a small feature. It is not. Behind “let customers use their own domain” sits DNS, certificate automation, SNI, edge routing, tenant isolation, and a surprising number of ways to get taken over.
This is the guide I wish existed when we started. It is written from running this in production for more than 5,000 customer domains at Lindo.ai. The whole flow, end to end, is really just:
# 1. Customer points DNS at you
app.acme.com. CNAME proxy.yourapp.com.
# 2. You detect the hostname, verify ownership, and issue a cert
curl -X POST https://api.cnames.dev/v1/domains \
-H "Authorization: Bearer sk_live_..." \
-d '{"hostname":"app.acme.com","origin":"acme.internal"}'
# 3. First HTTPS request arrives; edge presents the right cert and routes to the tenant
GET https://app.acme.com -> TLS (SNI: app.acme.com) -> origin for tenant "acme"Everything below is the detail hiding inside those three steps. If you only take one thing away: the hard parts are DNS ergonomics and certificate lifecycle, not the proxy.
The two connection models: CNAME vs A record
There are exactly two ways a customer can send traffic for their domain to you, and the choice has long-term consequences.
CNAME to a hostname you own (preferred)
You give the customer a target like proxy.yourapp.com and they create:
app.acme.com. CNAME proxy.yourapp.com.The win is indirection. proxy.yourapp.com is a name you control, so you can change the IPs behind it — add anycast points, migrate providers, fail over a region — without touching a single customer's DNS. This is the model to push customers toward whenever the record is a subdomain.
A/AAAA record to your IPs
The customer points their domain straight at your IP addresses:
acme.com. A 203.0.113.10You need this at the apex (see below), but it hard-codes your IPs into thousands of zones you do not control. Only publish apex IPs that are stable anycast addresses you are prepared to keep forever. Renumbering them later is a multi-month migration where you are at the mercy of customers updating records.
The apex problem (and why www is easier)
Customers usually want the bare domain, acme.com, not www.acme.com. But DNS forbids a CNAME on a zone apex: the apex must carrySOA and NS records, and RFC 1034 says a CNAME cannot coexist with any other record on the same name. So acme.com CNAME proxy.yourapp.com is simply invalid.
Your realistic options, in rough order of preference:
- ALIAS / ANAME: a provider-side flattened CNAME (Route 53 alias, Cloudflare CNAME flattening, DNSimple ALIAS). Works great — if the customer's provider supports it. Many do not.
- Apex A/AAAA to anycast IPs: universally supported, at the cost of hard-coding your IPs.
- Redirect the apex to www: put the app on
www(a CNAME) and 301 the apex. Clean, but some customers dislike the visiblewww.
This deserves its own treatment — see the CNAME-at-apex problem, and every way to solve it. When you do let customers self-serve DNS, tools like Domain Connect can set the right records for them in one click on supported providers.
How TLS certificates actually get issued
Once DNS points at you, HTTPS needs a certificate for each hostname. In 2026 this is a solved problem via ACME and free CAs like Let's Encrypt or ZeroSSL — but you must understand the validation methods, because they dictate your architecture.
HTTP-01
The CA gives you a token; you serve it at http://app.acme.com/.well-known/acme-challenge/<token>; the CA fetches it over port 80. This is the natural fit for custom domains: the moment a customer's CNAME resolves to your edge, your edge can answer the challenge. No access to the customer's DNS required.
TLS-ALPN-01
Validation happens inside a special TLS handshake on port 443 using the acme-tls/1 ALPN protocol. Useful when port 80 is blocked. Also requires the domain to already resolve to you.
DNS-01
You prove control by creating a _acme-challenge TXT record. This is the only method that issues wildcard certificates — but it needs write access to the zone. For customer-owned domains you rarely have that, so DNS-01 is for your own domains (issuing *.yourapp.com), not for customer domains.
On-demand TLS
The pattern that makes custom domains scale: do not pre-issue anything.When the first TLS handshake for an unknown hostname arrives, check an allow-list (is this a domain a tenant registered with us?), and if yes, issue the certificate inline and cache it. Caddy calls this on-demand TLS; you can build the same with any ACME client plus a fast “is this host allowed” lookup. The allow-list check is non-negotiable — without it, anyone pointing a domain at you can burn your CA rate limits.
Rate limits are real. Let's Encrypt's well-known limits include roughly 50 certificates per registered domain per week and a cap on duplicate certificates; the new-orders limits are per-account. For custom domains, each customer domain is its own registered domain, so the per-domain cap rarely bites — but a retry storm against the account-level limits absolutely will. Always verify the current numbers in the Let's Encrypt rate-limit docs before you design your retry logic.
SNI: how the edge picks the right certificate
One IP serves thousands of domains. When a browser opens a TLS connection it sends the target hostname in the SNI field of the ClientHello, before any certificate is chosen. Your edge reads SNI, loads (or issues) the certificate for exactly that hostname, and completes the handshake. This is why on-demand TLS works: SNI tells you which cert you need at the exact moment you need it.
Practical implication: your certificate store is keyed by hostname, and your hot path is a lookup by SNI. Miss the cache and you either issue (first ever request for that host) or serve from a shared store. Keep that lookup in memory or a fast KV; it is on every cold connection.
What happens between the CNAME and the first byte
A request to a custom domain travels through more stages than people expect:
- DNS:
app.acme.comresolves via the CNAME chain to your anycast edge. - TCP + TLS: connection lands on the nearest edge node; SNI selects the cert.
- Host lookup: map the hostname to a tenant and an origin.
- Header shaping: set
Host, forward the real client IP, inject per-tenant headers. - Origin fetch: proxy to the tenant's backend (which may itself be multi-tenant).
- Response: stream back, optionally rewriting redirects and cookies.
Two details cause most production pain. First, the real client IP: once you proxy, the origin sees your IP unless you forward the client's address (via X-Forwarded-For / X-Real-IP, or PROXY protocol). Rate limiters and geo logic downstream depend on getting this right. Second, the Host header: your origin needs to know which tenant this is — either preserve the original Host or map it to an internal identifier the backend understands.
Tenant routing and isolation
The core data structure is a mapping from hostname → tenant → origin + policy. Everything else is a cache of it.
{
"app.acme.com": {
"tenant": "acme",
"origin": "acme-cluster.internal:8080",
"headers": { "X-Tenant-Id": "acme" },
"verified": true
}
}Isolation matters more than it looks. A bug that routes app.acme.com to tenant globex is a cross-tenant data breach, not a 404. Design so that an unverified or unknown host fails closed — it should never fall through to a default tenant. Per-tenant header profiles, redirect rules, and origin overrides let you give each customer correct behavior without forking your proxy.
Wildcards vs per-domain certificates
Two different jobs that people conflate:
- Subdomains you hand out (
acme.yourapp.com,globex.yourapp.com): cover these with a single*.yourapp.comwildcard, issued once via DNS-01 since you own the zone. - Customer-owned domains (
app.acme.com): each gets its own certificate via HTTP-01, issued on demand. There is no wildcard shortcut here — you do not control those zones.
Security: domain takeover and dangling CNAMEs
This is where custom-domain features quietly become vulnerabilities. Three rules:
- Verify before you route. Require a proof step — a unique TXT record or a specific CNAME target — before you consider a domain owned by a tenant. Otherwise anyone can claim
login.microsoft.comin your dashboard and, if you auto-issue, get a valid cert served from your edge. - De-provision promptly. When a customer removes a domain or churns, stop serving it and drop the cert. A hostname that still resolves to you but is no longer “owned” in your system is an open door.
- Kill dangling CNAMEs. If you tell customers to CNAME to
proxy.yourapp.comand you ever release that target, every customer pointing at it can be hijacked. Treat your proxy hostnames as permanent.
You can sanity-check any customer's setup with our free CNAME checker and SSL checker — no signup.
Build vs buy
You can absolutely build all of this. Caddy with on-demand TLS plus an allow-list endpoint gets you a working single node in a weekend. The cost shows up later: anycast for latency, multi-node certificate storage and sharing, rate-limit-aware retries, DDoS posture, observability per tenant, and the on-call that comes with being in the TLS path for someone else's production traffic. We wrote an honest breakdown in cnames.dev vs self-hosting Caddy on-demand TLS, and how the managed CDN-native option compares in Cloudflare for SaaS alternative.
Buying it means one API call per domain and none of the lifecycle burden. That is exactly what cnames.dev does, and it is the same infrastructure we run for website builders, AI app builders, and agencies today.
Summary
- Push customers to CNAME a subdomain to a hostname you own; use apex A/anycast or redirect-to-www only where CNAME is impossible.
- Issue certs on demand with HTTP-01 behind a strict allow-list; reserve DNS-01 and wildcards for your own zone.
- Route by SNI → hostname → tenant, and fail closed on unknown hosts.
- Forward the real client IP and the correct Host to the origin.
- Verify ownership before routing, de-provision on churn, and never dangle a CNAME target.