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


Custom domains for SaaS let your customers use your product on a domain they own, like app.customer.com instead of customer.yourapp.com. To offer them, you point the customer's DNS at your servers, verify they control the domain, issue an HTTPS certificate for it, and route each request to the right tenant.
Sooner or later, a customer asks for it. Often it's the deal you really want: an enterprise team that needs its own URL, or an agency that wants to resell your product under its own brand. The DNS part takes an afternoon. Doing it safely, for thousands of domains you don't control, takes a lot longer.
We run a custom domains proxy for other SaaS companies, so this guide also covers the parts that only show up in production.
In this guide, you'll learn:
- Why customers ask for custom domains
- How a request on a custom domain reaches your app
- Which DNS records your customers need: CNAME, A or ALIAS
- How to verify ownership and issue HTTPS certificates safely
- How to route each request to the right tenant
- What breaks in production, and whether to build or buy
Here's what to know before you build.
Why customers ask for custom domains
A custom domain makes your product look like part of your customer's business instead of a tool they rent. In practice, the requests come from a few places:
- Their website or storefront runs on your platform. Site builders, job boards, link-in-bio tools and shops all need this. Supertape, a website platform for musicians, needed its users' sites on their own domains, including root domains.
- They want login pages on their own domain. Memberstack's single sign-on screen used to live on memberstack.com, and they moved it onto each customer's domain so users never leave the customer's site to sign in.
- Agencies and resellers want to sell your product as their own. Dropboard shipped white-label hiring portals in time for its AppSumo launch.
- If you send email on behalf of customers, they want every link in it on their own domain, for trust and deliverability.
- A short URL on their own domain is easier to remember:
status.yourcompany.combeatsapp.vendor.com/status-page/your-company-ac23.
How a request on a custom domain reaches your app
Before any code, it helps to see the whole path once.

- A visitor opens
https://app.customer.com. - DNS resolves
app.customer.com. Your customer has pointed it at your proxy, e.g. with a CNAME toin.yourapp.com. - Your proxy completes the TLS handshake using a certificate for
app.customer.com. - The proxy forwards the request to your app and adds a header with the original hostname.
- Your app looks up which tenant owns
app.customer.comand serves their content.
Each step fails in its own way. The rest of this guide goes through them in order.
DNS: CNAME for subdomains, A records or ALIAS for root domains
How does the customer's domain even send someone to your application? They create a DNS record or two that point their domain at your app. Which record depends on the domain:
- Subdomain (
app.customer.com): a CNAME to a hostname you control, such asin.yourapp.com. This is the best option, because you can change the servers behindin.yourapp.comwhenever you want. - Root domain (
customer.com): DNS doesn't allow a CNAME at the root of a zone, so the customer creates A records pointing at your IP addresses. - Root domain on a modern DNS provider: some providers offer ALIAS or ANAME records, or CNAME flattening. These behave like a CNAME at the root.
- www: a CNAME for
www.customer.com, plus a redirect betweencustomer.comandwww.customer.com, so both work.
In zone-file form, that looks like this:
; Subdomain: point at a hostname you control
app.customer.com. CNAME in.yourapp.com.
; Root domain: point at static IPs
customer.com. A 203.0.113.10
customer.com. A 203.0.113.11If you use A records, make sure your IPs are static and never change, ever! If they change, you'll have a bad time. All the A records your customers created will point to an IP that no longer belongs to you. Get ready for a sleepless night.
Static means more than "we haven't changed it yet". Use addresses that survive replacing servers and load balancers, such as AWS Global Accelerator or Elastic IPs on a network load balancer. Our customers point root domains at two static anycast IPs, so we can add and replace proxies behind them without asking anyone to touch their DNS.
Note: some DNS providers, e.g. Cloudflare, let you put a CNAME on the root domain using a technique called CNAME flattening. Cloudflare looks up the IPs your CNAME points to and answers with A records for the root domain on your behalf.
Common DNS mistakes
These cause most "it doesn't work" tickets:
- An old A or AAAA record is still there next to the new one. The AAAA record is the sneaky one: visitors on IPv6 keep landing on the old host.
- They typed the full name in the Host field. Some providers append the domain automatically, so
app.customer.combecomesapp.customer.com.customer.com. - The record is proxied on Cloudflare instead of "DNS only". More on that below.
- They expect it to work instantly, but old records stay cached until their TTL expires.
Great! Now we know how to point traffic from the customer's domain to our own app. Easy-peasy, yeah? Not so fast, Hackerman.
Verify the customer controls the domain
Anyone can type any domain into your settings page. If you start serving a domain, or requesting certificates for it, just because someone typed it, two things can go wrong:
- A user claims a domain that belongs to someone else, maybe another of your tenants, and hijacks its traffic.
- Someone points thousands of domains at your servers and makes you request certificates for all of them.
There are three common ways to check ownership:
- The routing record itself. If
app.customer.comresolves through a CNAME chain to your target, the customer controls its DNS. For most products this is enough. - A TXT token. You ask for a record like
_yourapp-verify.app.customer.comcontaining a random token. This proves ownership before any traffic moves, which is useful for zero-downtime migrations. - An HTTP file. You serve a token at a well-known path. This is less common, because it only works once traffic already reaches you.
Here's a check that a hostname's CNAME chain reaches your target:
require "resolv"
def points_to_us?(hostname, target = "in.yourapp.com")
name = hostname.downcase
10.times do
return true if name == target
record = Resolv::DNS.open do |dns|
dns.getresource(name, Resolv::DNS::Resource::IN::CNAME)
end
name = record.name.to_s.downcase
end
false
rescue Resolv::ResolvError
false # no CNAME at this hop
endimport { promises as dns } from 'node:dns'
export async function pointsToUs(hostname, target = 'in.yourapp.com') {
let name = hostname.toLowerCase()
for (let hop = 0; hop < 10; hop++) {
if (name === target) return true
try {
const [next] = await dns.resolveCname(name)
name = next.replace(/\.$/, '').toLowerCase()
} catch {
return false // no CNAME at this hop
}
}
return false
}import dns.resolver # pip install dnspython
def points_to_us(hostname, target="in.yourapp.com"):
name = hostname.lower()
for _ in range(10):
if name == target:
return True
try:
answer = dns.resolver.resolve(name, "CNAME")
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
return False # no CNAME at this hop
name = answer[0].target.to_text().rstrip(".").lower()
return Falsefunction pointsToUs(
string $hostname,
string $target = 'in.yourapp.com',
): bool {
$name = strtolower($hostname);
for ($hop = 0; $hop < 10; $hop++) {
if ($name === $target) {
return true;
}
$records = dns_get_record($name, DNS_CNAME);
if (empty($records)) {
return false; // no CNAME at this hop
}
$name = strtolower(rtrim($records[0]['target'], '.'));
}
return false;
}A few rules that save you later:
- Allow only one tenant per hostname, and enforce it with a unique index, not just in the UI.
- Re-check domains periodically. Domains expire and change hands, and a record that still points at you after the customer leaves is how subdomain takeovers happen.
- For wildcards, reject public suffixes. Nobody should be able to claim
*.co.ukor*.github.io; check against the Public Suffix List.
HTTPS: issuing and renewing certificates
If your customers just point their domains at your app and visit them, they're in for a nasty surprise. Your app doesn't support HTTPS for these domains yet, so the browser shows an ugly warning and blocks the page.
To enable HTTPS, put a reverse proxy in front of your application server to handle TLS termination. Your customers' DNS records point at the proxy. It handles TLS and forwards requests to your app.
The proxy needs a certificate for every domain it serves. You could ask customers to generate certificates and send them to you, but there's a better way. Once a customer points their domain at your proxy, you control enough of it to get a certificate yourself.
Certificate Authorities (CAs) like Let's Encrypt and ZeroSSL issue them for free. Both use the ACME protocol: an ACME client proves to the CA that you control the domain, then gets a certificate for it.
There are many ACME clients. Certbot is the classic, but for custom domains you usually want one that also terminates TLS and proxies requests, such as Caddy or lua-resty-auto-ssl for nginx.
HTTP-01 vs DNS-01 validation
The CA has to check that you control the domain. There are two ways:
- HTTP-01: the CA fetches a token from
http://app.customer.com/.well-known/acme-challenge/on your proxy. It works as soon as the customer's record points at you and needs nothing extra from them. It can't issue wildcard certificates, and it breaks if something in front of you blocks or rewrites that path. - DNS-01: the CA looks for a TXT record at
_acme-challenge.app.customer.com. It works before traffic moves to you, and it's the only way to get wildcard certificates. Since you can't edit your customer's DNS, ask them for a one-time CNAME that delegates the challenge to a zone you control:
_acme-challenge.app.customer.com. CNAME _acme-challenge.app.customer.com.challenges.yourapp.com.That's the setup we ask for when a customer needs a certificate before switching traffic, or wants a wildcard domain.
Issue certificates on demand, but only for domains you know
With thousands of domains, you don't want to list each one in a config file. Caddy's on-demand TLS gets a certificate during the first TLS handshake for a new hostname.
There's a catch. Without a check, anyone can point a domain at your IP and make you request a certificate for it. That burns through your CA rate limits and fills up your storage. So Caddy asks your app first. A minimal Caddyfile:
{
on_demand_tls {
ask http://localhost:3000/caddy/ask
}
}
https:// {
tls {
on_demand
}
reverse_proxy localhost:3000
}Then the ask endpoint. Caddy calls it with ?domain=app.customer.com, and it returns 200 only for domains you've verified:
# config/routes.rb: get "/caddy/ask", to: "caddy#ask"
class CaddyController < ActionController::API
def ask
domain = params[:domain].to_s.downcase
if CustomDomain.exists?(host: domain, verified: true)
head :ok
else
head :not_found
end
end
end// Express
app.get('/caddy/ask', async (req, res) => {
const domain = String(req.query.domain || '').toLowerCase()
const known = await CustomDomain.findOne({
where: { host: domain, verified: true },
})
res.sendStatus(known ? 200 : 404)
})# urls.py: path("caddy/ask", views.caddy_ask)
from django.http import HttpResponse
def caddy_ask(request):
domain = request.GET.get("domain", "").lower()
known = CustomDomain.objects.filter(
host=domain, verified=True
).exists()
return HttpResponse(status=200 if known else 404)// routes/web.php
use App\Models\CustomDomain;
use Illuminate\Http\Request;
Route::get('/caddy/ask', function (Request $request) {
$domain = strtolower((string) $request->query('domain'));
$known = CustomDomain::where('host', $domain)
->where('verified', true)
->exists();
return response('', $known ? 200 : 404);
});The ask endpoint is on the path of every handshake for a hostname Caddy hasn't seen yet, so make it fast and cache its answers. We cache ours for 30 minutes.
What blocks certificates
- CAA records. If the customer's domain has a CAA record that doesn't list your CA, issuance fails. CAA is inherited from parent domains, so a record on
customer.comalso coversapp.customer.com. Let's Encrypt needsletsencrypt.org; ZeroSSL needssectigo.com. - Rate limits. Let's Encrypt allows 50 certificates per registered domain every 7 days, 300 new orders per account every 3 hours, and 5 failed validations per hostname per account per hour (limits as of October 2026). One customer with lots of subdomains, or one customer retrying a broken setup, can hit these. Keep a second CA as a fallback; we use both Let's Encrypt and ZeroSSL.
- Shorter lifetimes. Let's Encrypt certificates last 90 days today. The default drops to 64 days on February 10, 2027 and to 45 days on February 16, 2028 (announcement). Renewal has to be automatic, and you need an alert when it fails.
Route the request to the right tenant
Your proxy now issues certificates, renews them, terminates TLS and forwards requests to your backend. But your app still needs to know which domain the request came in on.
The way we solve this is by having the proxy add a header with the original hostname. Our proxy sends X-Served-For: app.customer.com and sets Host to your app's own hostname, so your existing hosting setup keeps working. Your app reads the header, finds the tenant, and serves only their data:
class ApplicationController < ActionController::Base
before_action :set_tenant
private
def set_tenant
host = request.headers["X-Served-For"].presence || request.host
@tenant = Tenant.find_by!(custom_domain: host.downcase)
end
end// Express middleware
app.use(async (req, res, next) => {
const host = (req.get('X-Served-For') || req.hostname).toLowerCase()
req.tenant = await Tenant.findOne({ where: { customDomain: host } })
if (!req.tenant) return res.status(404).send('Unknown domain')
next()
})from django.http import Http404
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
host = request.headers.get("X-Served-For") or request.get_host()
host = host.lower()
tenant = Tenant.objects.filter(custom_domain=host).first()
if tenant is None:
raise Http404("Unknown domain")
request.tenant = tenant
return self.get_response(request)// app/Http/Middleware/ResolveTenant.php
namespace App\Http\Middleware;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
class ResolveTenant
{
public function handle(Request $request, Closure $next)
{
$host = $request->header('X-Served-For') ?? $request->getHost();
$tenant = Tenant::where('custom_domain', strtolower($host))
->firstOrFail();
$request->attributes->set('tenant', $tenant);
return $next($request);
}
}Check that the request came through your proxy
Your app now trusts X-Served-For to decide whose data to show. That's a problem if anyone can reach your app directly. Someone who finds your app's own address can skip the proxy, send X-Served-For: their-target.com, and see another tenant's pages.
There are two ways to close that gap, and you can use both:
- Keep your app private. Only accept traffic from your proxy's IP addresses, with a firewall or security group. Not every host lets you do this.
- Have the proxy sign every request. The proxy adds a secret token to each request it forwards, and your app rejects any request that claims a custom domain without it. This works anywhere, including Heroku or Render.
Our proxy does the second one for you. Each upstream has an auth token (set it in the dashboard or the API), and we send it with every forwarded request in the X-SaaS-Domains-Auth-Token header (docs).
Keep the token in an environment variable, check it before the tenant lookup, and compare it in constant time so the check doesn't leak how much of a guess was right. Requests without X-Served-For, i.e. to your own domain, carry on as normal:
class ApplicationController < ActionController::Base
# Runs before set_tenant
prepend_before_action :verify_proxy_token
private
def verify_proxy_token
return unless request.headers["X-Served-For"]
token = request.headers["X-SaaS-Domains-Auth-Token"].to_s
expected = ENV.fetch("PROXY_AUTH_TOKEN")
valid = ActiveSupport::SecurityUtils.secure_compare(token, expected)
head :forbidden unless valid
end
endimport { timingSafeEqual } from 'node:crypto'
const expected = Buffer.from(process.env.PROXY_AUTH_TOKEN)
// Register before the tenant middleware
app.use((req, res, next) => {
if (!req.get('X-Served-For')) return next()
const token = Buffer.from(req.get('X-SaaS-Domains-Auth-Token') || '')
const valid =
token.length === expected.length && timingSafeEqual(token, expected)
if (!valid) return res.sendStatus(403)
next()
})import hmac
import os
from django.http import HttpResponseForbidden
class ProxyTokenMiddleware:
# List it before TenantMiddleware 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)// app/Http/Middleware/VerifyProxyToken.php (run before ResolveTenant)
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class VerifyProxyToken
{
public function handle(Request $request, Closure $next)
{
if ($request->hasHeader('X-Served-For')) {
$token = $request->header('X-SaaS-Domains-Auth-Token', '');
$expected = config('services.proxy.token');
abort_unless(hash_equals($expected, $token), 403);
}
return $next($request);
}
}Treat the token like any other secret: keep it out of your repo, and change it if it leaks.
Some more things to watch:
- If a CDN or load balancer sits between the proxy and your app, make sure it passes
X-Served-Forand the token through instead of stripping them. - Watch what happens to
X-Forwarded-Host. Rails buildsrequest.base_urlfrom it and compares that with the browser'sOriginheader on form posts. If your proxy setsX-Forwarded-Hostto anything other than the domain in the browser, forms fail with "HTTP Origin header didn't match request.base_url". Two of our customers ran into this, so we made it possible to leave the header untouched. - OAuth providers only redirect to callback URLs you registered in advance, so "Sign in with Google" on thousands of custom domains won't work directly. Route the callback through your main domain and send the user back. Emails and other absolute links should use the tenant's domain.
Framework-specific walkthroughs: Rails, Django and Node.js.
When your customer's domain is behind Cloudflare
Lots of your customers will manage DNS on Cloudflare, where a record can be "Proxied" (orange cloud) or "DNS only" (grey cloud). When it's proxied, Cloudflare sits between visitors and your proxy:
- DNS checks see Cloudflare's IPs, not your CNAME target, so your verification says the domain isn't pointed at you.
- Cloudflare terminates TLS itself and then connects to your proxy. If you don't have a valid certificate for the domain yet, visitors get error 525 (SSL handshake failed) or 526 (invalid SSL certificate).
- HTTP-01 challenges can be redirected or blocked by the customer's Cloudflare settings.
The simple fix is to ask the customer to set the record to DNS only. If they need Cloudflare's proxy, issue the certificate with DNS-01 first, then have them use the "Full (strict)" SSL mode.
Give customers DNS instructions they can follow
The person adding your custom domain is often not the person who manages DNS. Clear instructions save you more support time than anything else in this guide.
- Show the exact records for their case: subdomain, root domain, www or wildcard. Use their real hostname, not
example.com, and add copy buttons. - Explain the Host field. Providers want
@, a blank field,app, or the full name, so tell them exactly what to type. - Let the user forward the instructions to whoever runs their DNS. We email step-by-step DNS instructions for this.
- Show live status, like "Waiting for DNS", "Issuing certificate" and "Live", and re-check automatically.
- Say exactly what's wrong. "Found an A record for 192.0.2.1, expected a CNAME to in.yourapp.com" fixes itself. "Domain not verified" becomes a support ticket.
Running it in production
To achieve high availability and to handle more traffic, use a fleet of reverse proxies behind a load balancer or anycast IPs. Use a service like AWS ECS or Elastic Beanstalk for automated recovery when a proxy fails. Otherwise, you may wake up in the middle of the night restarting proxies manually.
That's the start. Here's what else we had to build:
- Certificate storage that every proxy shares. Every proxy needs every certificate, and you don't want each one requesting its own. Caddy supports pluggable storage; we keep certificates in AWS.
- Monitoring for certificate expiry and failed renewals, plus regular DNS re-checks for every domain. Customers change DNS and forget to tell you.
- Limits per tenant, so one customer's traffic spike or broken upstream doesn't slow down everyone else. Per-domain rate limits and circuit breakers keep problems contained.
- A way to handle abuse. Custom domains attract phishing pages and people streaming huge files, and you need to spot them and switch off a single domain quickly.
- Logs and analytics per domain. When a customer says "my site is down", you need to see their traffic, not just your averages.
Launch checklist
Before you let the first customer in, make sure you have:
- CNAME target and static IPs for root domains documented
- Ownership verification, with one tenant per hostname
- An ask endpoint (or equivalent) before issuing any certificate
- CAA handling, rate-limit awareness and a second CA
- Automatic renewal with expiry alerts
- The original hostname in a header, trusted only with the proxy's secret token
- Certificate storage shared across all proxies
- A DNS status page with clear instructions
- Per-domain limits and a way to disable a single domain
Build it or buy it
Everything above is buildable. The question is whether you want to own it.
- Build it yourself with Caddy, or nginx plus an ACME client. You pay for servers and engineering time. You get full control, and you also own on-call, abuse, rate limits and renewals. It's a good fit for a handful of domains on one server.
- Cloudflare for SaaS: 100 custom hostnames included on the Free, Pro and Business plans, then $0.10 per hostname per month, up to 50,000 hostnames. Root-domain proxying is a paid add-on on Enterprise (plans as of October 2026). It's a good fit if you're already on Cloudflare and your customers use subdomains. Here's how we compare.
- A managed custom domains service. At SaaS Custom Domains, customers point a CNAME or our static IPs at us. We verify domains, issue and renew certificates, and proxy traffic to your app with the original hostname in a header. Pricing is $0.29 per domain per month with a 100-domain minimum ($29/month), with volume discounts from 1,000 domains.
Val Town estimates it saved more than 450 engineering hours by not building this itself. Your number will depend on how many domains you expect and how much of the production list you'd build anyway.
Frequently asked questions
What is a custom domain in SaaS?
It's a domain your customer owns, like app.customer.com, that serves your product instead of a subdomain of yours. The customer points it at your servers with a DNS record. You handle the certificate and serve their tenant on it.
Should customers use a CNAME or an A record?
Use a CNAME for subdomains like app.customer.com, so you can change your servers without asking customers to update anything. Use A records (or ALIAS/flattening, where the provider supports it) only for root domains like customer.com, and only with IPs that will never change.
Can customers use their root domain?
Yes, but not with a plain CNAME. They need A records pointing at your static IPs, or an ALIAS-style record if their DNS provider supports one. Add a redirect so www works too.
How long does a DNS change take?
Usually minutes. Old records can stay cached until their TTL expires, which for some providers means up to 48 hours. Your app should keep re-checking instead of failing once.
Do I need to pay for SSL certificates?
No. Let's Encrypt and ZeroSSL issue certificates for free through the ACME protocol. You pay for running the infrastructure that requests, stores and renews them.
How do I stop someone from claiming a domain they don't own?
Verify the domain before serving it or issuing a certificate: check that its DNS points at you, or ask for a TXT token. Allow only one tenant per hostname, and re-check domains regularly.
Next steps
Pick your stack and follow the walkthrough:
- Ruby on Rails guide to custom domains
- Django guide to custom domains
- Node.js guide to custom domains
- Beginner's guide to reverse proxies
Or skip the infrastructure: start a 7-day trial of SaaS Custom Domains, or book a 15-minute demo. Questions? Email us at sales@saascustomdomains.com.


