Multi-Tenant Vanity URLs with Cloudflare for SaaS, DNS and Nginx
Building a SaaS product and want to let your customers use their own custom domains like app.theirclient.com instead of theirclient.yoursaas.com? This is what we call vanity URLs or custom domains, and it’s a seriously impressive feature to offer your tenants.
In this post I’ll walk through the full stack — DNS setup in Cloudflare, Cloudflare for SaaS, Nginx configuration for a catch-all multi-tenant server, and how to programmatically detect and register custom domains using the Cloudflare API (with a PHP example). The backend is assumed to be Laravel, but this guide is intentionally generic.
If you haven’t set up your wildcard SSL and DNS yet, go read my previous post first: Wildcard SSL Certs in Azure with Cloudflare — a lot of what I cover here builds on that foundation.
The Architecture Overview
Here’s what we’re building:
tenant browser
│
▼
client.theirclient.com (CNAME → gateway.yoursaas.com)
│
▼
Cloudflare for SaaS (validates the custom hostname, applies your cert)
│
▼
Nginx (single catch-all config — accepts any domain, routes to your app)
│
▼
Your backend app (Laravel, Node, whatever — reads the Host header to identify tenant)
Your tenants set a simple CNAME in their DNS. Your Cloudflare for SaaS account handles SSL. Nginx accepts any incoming domain and forwards it upstream. The backend reads the Host header to work out which tenant is making the request.
Part 1: Your Gateway Domain and Tenant DNS Setup
Setting up gateway.yoursaas.com
Your gateway domain is the entry point tenants point their CNAME at. It’s a subdomain you own and control, and it’s the target your tenants will set in their DNS.
In Cloudflare DNS for your domain, create:
| Type | Name | Content | Proxy Status |
|---|---|---|---|
| CNAME | gateway | yoursaas-origin.azurewebsites.net | Proxied |
Or if you’re pointing directly to an IP / load balancer:
| Type | Name | Content | Proxy Status |
|---|---|---|---|
| A | gateway | 1.2.3.4 | Proxied |
This means gateway.yoursaas.com is live and proxied through Cloudflare.
Telling Your Tenants What to Do
This is the beautiful part — your tenants just need to add a single CNAME record in their DNS provider:
Type: CNAME
Name: app (or @ for root, depending on their domain)
Target: gateway.yoursaas.com
So app.theirclient.com → gateway.yoursaas.com → your servers.
Simple, clean, and easy to document in your onboarding flow.
Part 2: Cloudflare DNS — Wildcard, Origin Cert, and Your Own Domain
Wildcard DNS for Your Subdomains
Before tenants bring their own domains, your tenants likely have a subdomain on your platform (e.g. theirclient.yoursaas.com). You’ll want a wildcard record covering all of these:
| Type | Name | Content | Proxy Status |
|---|---|---|---|
| CNAME | * | yoursaas-origin.azurewebsites.net | Proxied |
| CNAME | @ | yoursaas-origin.azurewebsites.net | Proxied |
Cloudflare’s free Universal SSL handles the wildcard cert for *.yoursaas.com automatically. Users hitting theirclient.yoursaas.com get HTTPS with no extra config on your part.
Cloudflare Origin Certificate for Your Server
For the connection between Cloudflare and your origin (Nginx), you’ll want a Cloudflare Origin Certificate. This is a free cert issued by Cloudflare — not trusted by browsers directly, but Cloudflare validates it and trusts it in “Full (strict)” mode.
In Cloudflare: SSL/TLS → Origin Server → Create Certificate
- Hostnames:
yoursaas.comand*.yoursaas.com - Validity: 15 years
- Download the certificate (
.pem) and private key
Save these to your server, for example:
/etc/nginx/ssl/cloudflare-origin.pem
/etc/nginx/ssl/cloudflare-origin.key
Set SSL mode to Full (strict) in SSL/TLS → Overview.
Part 3: Nginx Configuration — Catch-All for All Domains
This is the key part most guides skip over. You need a single Nginx server block that accepts any domain — both your *.yoursaas.com subdomains and any custom domain your tenants have pointed at you.
The Catch-All Server Block
server {
listen 443 ssl;
server_name _; # Catch all domains
ssl_certificate /etc/nginx/ssl/cloudflare-origin.pem;
ssl_certificate_key /etc/nginx/ssl/cloudflare-origin.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Pass the real host to your app so it knows which tenant this is
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
location / {
proxy_pass http://127.0.0.1:8000; # Your app (Laravel, etc.)
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
The magic here is server_name _ — Nginx will accept any incoming hostname on port 443 and forward it upstream. Your backend then reads the Host header to identify the tenant.
In Laravel, that’s as simple as:
$host = request()->getHost(); // e.g. app.theirclient.com or theirclient.yoursaas.com
$tenant = Tenant::where('domain', $host)->firstOrFail();
Proxying a Specific Domain to a Different App
Sometimes a tenant needs their custom domain to point to a separate frontend (a Next.js app, a React SPA, a different service). You can handle this cleanly by adding a specific server block above the catch-all:
# Specific domain routed to a different upstream (e.g. a separate frontend)
server {
listen 443 ssl;
server_name app.specificclient.com;
ssl_certificate /etc/nginx/ssl/cloudflare-origin.pem;
ssl_certificate_key /etc/nginx/ssl/cloudflare-origin.key;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
location / {
proxy_pass http://127.0.0.1:3000; # Their separate frontend app
}
}
# The catch-all sits below and handles everything else
server {
listen 443 ssl;
server_name _;
# ... as above
}
Nginx evaluates server_name matches in order of specificity — an exact name match always wins over _. So app.specificclient.com gets routed to port 3000, everything else hits your main app on port 8000.
Part 4: Cloudflare for SaaS
This is the feature that makes all of this work for custom domains. Cloudflare for SaaS lets you issue SSL certificates for domains your tenants own — domains that aren’t in your Cloudflare account.
When app.theirclient.com hits Cloudflare (because the tenant set a CNAME to gateway.yoursaas.com), Cloudflare for SaaS recognises that hostname, presents the correct certificate, and proxies the request to your origin.
Enabling Cloudflare for SaaS
- In your Cloudflare dashboard, go to SSL/TLS → Custom Hostnames
- Click Enable Cloudflare for SaaS
- Set your Fallback Origin — this is where unrecognised hostnames fall back to. Set it to
gateway.yoursaas.com(or your origin directly)
You’re now ready to start adding custom hostnames.
Adding a Custom Hostname Manually (to understand the flow)
In SSL/TLS → Custom Hostnames, click Add Custom Hostname:
- Hostname:
app.theirclient.com - Certificate type: Managed (Cloudflare handles issuance and renewal)
Cloudflare will verify that the domain is actually pointing at you (via the CNAME your tenant set), then issue a certificate. Once verified, SSL just works for that domain.
Verification Status
Cloudflare custom hostnames go through states:
pending_validation— awaiting DNS checkactive— DNS verified, cert issued, all goodpending_deletion— being removed
You need to poll the API to detect when a tenant’s hostname becomes active.
Part 5: Automating with the Cloudflare API (PHP)
Here’s where it gets fun. When a tenant adds their custom domain in your app, you need to:
- Check that their DNS CNAME is actually pointing at
gateway.yoursaas.com - Register the hostname with Cloudflare for SaaS via the API
- Poll until it’s verified and active
Cloudflare API Credentials You’ll Need
CF_ZONE_ID— your zone ID (from the Cloudflare dashboard overview page)CF_API_TOKEN— an API token withZone → SSL and Certificates → Editpermission
PHP: Register a Custom Hostname with Cloudflare for SaaS
<?php
class CloudflareForSaas
{
private string $apiToken;
private string $zoneId;
private string $baseUrl = 'https://api.cloudflare.com/client/v4';
public function __construct(string $apiToken, string $zoneId)
{
$this->apiToken = $apiToken;
$this->zoneId = $zoneId;
}
/**
* Check if a domain's CNAME is pointing at our gateway.
*/
public function isDnsReady(string $domain, string $expectedCname = 'gateway.yoursaas.com'): bool
{
$records = dns_get_record($domain, DNS_CNAME);
foreach ($records as $record) {
if (isset($record['target']) && rtrim($record['target'], '.') === $expectedCname) {
return true;
}
}
return false;
}
/**
* Register a custom hostname with Cloudflare for SaaS.
* Returns the custom hostname ID on success.
*/
public function addCustomHostname(string $hostname): array
{
$response = $this->request('POST', "/zones/{$this->zoneId}/custom_hostnames", [
'hostname' => $hostname,
'ssl' => [
'method' => 'http',
'type' => 'dv',
'settings' => [
'min_tls_version' => '1.2',
],
],
]);
return $response;
}
/**
* Get the current status of a custom hostname.
*/
public function getCustomHostnameStatus(string $customHostnameId): array
{
return $this->request('GET', "/zones/{$this->zoneId}/custom_hostnames/{$customHostnameId}");
}
/**
* List all custom hostnames, optionally filtering by hostname.
*/
public function findCustomHostname(string $hostname): ?array
{
$response = $this->request('GET', "/zones/{$this->zoneId}/custom_hostnames", [
'hostname' => $hostname,
]);
return $response['result'][0] ?? null;
}
/**
* Delete a custom hostname (e.g. when tenant removes their domain).
*/
public function deleteCustomHostname(string $customHostnameId): bool
{
$response = $this->request('DELETE', "/zones/{$this->zoneId}/custom_hostnames/{$customHostnameId}");
return $response['success'] ?? false;
}
/**
* Make an authenticated request to the Cloudflare API.
*/
private function request(string $method, string $endpoint, array $data = []): array
{
$url = $this->baseUrl . $endpoint;
$options = [
'http' => [
'method' => $method,
'header' => implode("\r\n", [
'Authorization: Bearer ' . $this->apiToken,
'Content-Type: application/json',
]),
'ignore_errors' => true,
],
];
if ($method === 'GET' && !empty($data)) {
$url .= '?' . http_build_query($data);
} elseif (!empty($data)) {
$options['http']['content'] = json_encode($data);
}
$context = stream_context_create($options);
$response = file_get_contents($url, false, $context);
return json_decode($response, true) ?? [];
}
}
PHP: The Full Flow When a Tenant Adds Their Domain
<?php
// Example: tenant submits app.theirclient.com through your dashboard
$cf = new CloudflareForSaas(
apiToken: env('CF_API_TOKEN'),
zoneId: env('CF_ZONE_ID')
);
$tenantDomain = 'app.theirclient.com';
// Step 1: Check their DNS is pointing at us
if (!$cf->isDnsReady($tenantDomain)) {
// Tell the tenant to add their CNAME first
return response()->json([
'error' => 'DNS not ready. Please add a CNAME record for ' . $tenantDomain . ' pointing to gateway.yoursaas.com',
], 422);
}
// Step 2: Register with Cloudflare for SaaS
$result = $cf->addCustomHostname($tenantDomain);
if (!$result['success']) {
// Could already exist, or API error
$errors = $result['errors'] ?? [];
Log::error('Cloudflare custom hostname registration failed', $errors);
return response()->json(['error' => 'Failed to register domain with Cloudflare'], 500);
}
$customHostnameId = $result['result']['id'];
// Step 3: Save the hostname ID to your database for later status checks
$tenant->update([
'custom_domain' => $tenantDomain,
'cf_custom_hostname_id' => $customHostnameId,
'custom_domain_status' => 'pending',
]);
// At this point, tell the tenant their domain is being verified.
// You'll poll/webhook to flip the status to 'active'.
PHP: Polling for Verification Status
You’d typically run this in a scheduled job (e.g. Laravel’s schedule:run) to check pending domains:
<?php
// In a Laravel Command or Job
$pendingTenants = Tenant::where('custom_domain_status', 'pending')
->whereNotNull('cf_custom_hostname_id')
->get();
foreach ($pendingTenants as $tenant) {
$status = $cf->getCustomHostnameStatus($tenant->cf_custom_hostname_id);
$hostnameStatus = $status['result']['status'] ?? 'pending';
$sslStatus = $status['result']['ssl']['status'] ?? 'pending';
if ($hostnameStatus === 'active' && $sslStatus === 'active') {
$tenant->update(['custom_domain_status' => 'active']);
// Notify the tenant their custom domain is live
$tenant->user->notify(new CustomDomainActivated($tenant->custom_domain));
}
}
The status field in the Cloudflare response will move from pending_validation → active once the CNAME is verified and the certificate is issued. This usually takes a few minutes.
Putting It All Together
Here’s the end-to-end summary of what happens when a new tenant sets up their custom domain:
- Tenant signs up — they get
theirclient.yoursaas.comby default (covered by your wildcard DNS and Cloudflare Universal SSL, no config needed) - Tenant wants a vanity URL — they enter
app.theirclient.comin your dashboard - Your app checks DNS —
dns_get_record()confirms they’ve set the CNAME togateway.yoursaas.com - Your app calls the Cloudflare API — registers
app.theirclient.comas a custom hostname - Cloudflare validates and issues a cert — usually within a few minutes
- Your polling job detects active status — updates your DB, notifies the tenant
- Nginx accepts the request — the catch-all block receives
app.theirclient.com, passesHostheader upstream - Your backend resolves the tenant — reads the
Hostheader, looks up the tenant in the DB, serves the right data
Security Considerations
- Always verify the CNAME before registering with Cloudflare — don’t let someone register a domain they don’t control
- Store your Cloudflare API token securely — use environment variables, never commit to source control
- Set SSL to Full (strict) — ensures Cloudflare validates your origin certificate
- Restrict your Nginx origin to Cloudflare IPs — you can whitelist Cloudflare’s published IP ranges so only Cloudflare can reach your origin directly
Cloudflare publishes their IP ranges at https://www.cloudflare.com/ips/ — worth adding a firewall rule to your server to only accept traffic from those ranges on port 443.
Conclusion
Multi-tenant vanity URLs sound complex, but the pieces fit together elegantly:
- ✅ Wildcard DNS handles your own subdomains automatically
- ✅ Cloudflare for SaaS handles SSL issuance for tenant custom domains
- ✅ A single Nginx catch-all config accepts any domain
- ✅ The Cloudflare API makes the whole flow programmable
- ✅ Tenants just set one CNAME record — that’s it
The result is a professional, scalable custom domain system with automatic SSL — the kind of feature that makes your SaaS feel enterprise-grade without requiring enterprise-grade infrastructure.
Happy shipping! 🚀