Short answer: Vercel, Netlify, Lovable and Cloudflare Workers pick the app from the request's hostname. Forward a customer hostname they don't know and you get a 404 or an error before your code runs. Either register every customer domain on the platform, or send your own platform hostname as Host and carry the customer's hostname in a separate header.
This post is for teams whose SaaS runs on a managed platform and who want customers on their own domains, like careers.acme.com. It explains why the obvious setup fails, what each platform's docs say, the three ways around it, and what to change in your app (including Next.js Server Actions). For the whole lifecycle of a custom domain, start with How to add custom domains to your SaaS.
Why does a customer domain fail when a proxy sits in front of Vercel or Netlify?
Because these platforms are shared front doors. Many apps sit behind the same IP addresses, so the platform reads the hostname in the request (the TLS SNI value and the HTTP Host header) to decide which app you mean.
The usual custom-domain setup breaks that:
visitor ── https://careers.acme.com ──▶ your proxy ──▶ myapp.vercel.app
(TLS for Host: careers.acme.com
customer → "which project is that?"
domains) → 404Your proxy terminates TLS for careers.acme.com, then forwards the request with Host: careers.acme.com. The platform looks that hostname up, finds no project, site or Worker attached to it, and turns the request away. Your app never sees it.
Proxies do this by default. Caddy, for example, "passes through incoming headers, including Host, to the backend without modifications" (Caddy reverse_proxy docs). That is right for a server you run, where the Host header tells your app which tenant it is. It is wrong for a platform that routes on it.
What does each platform say?
Checked on each platform's own docs on 3 November 2026. "Not stated" means the page doesn't say.
| Platform | How it picks your app | What it needs before serving a hostname | Behaviour for an unknown hostname | Source |
|---|---|---|---|---|
| Vercel | The host header is "the domain name as it was accessed by the client", and it contains the custom domain when that domain is assigned to the deployment |
Add the domain in Settings → Domains or with the domains API. For a proxy in front: "Set the origin to a domain assigned to your Vercel project. Forward the correct HTTP Host header" and SNI for that hostname |
DEPLOYMENT_NOT_FOUND is returned "when a request is made for a deployment that doesn't exist" |
Request headers, Proxy guide, Error docs |
| Netlify | By the domains assigned to a site | Add the domain under Domain management, as the primary domain or a domain alias. "We recommend assigning no more than 50 domain aliases to a site." Domain-specific rules need the "domain must be assigned to the site" | Not stated in the docs pages above (we saw a 404, below) | Bring a domain, Domain aliases, Redirect options |
| Lovable | By the domains connected to a project | Connect each domain in Project → Settings → Domains (custom domains are on paid plans). Behind your own CDN or proxy, turn on "Uses Cloudflare or similar proxy" per domain; Lovable then gives a CNAME to your lovable.app URL |
Not stated (we saw a Cloudflare error page, below) | Lovable custom domains |
| Cloudflare Workers | Routes and Custom Domains match the request hostname | Routes need "an active Cloudflare zone", and "route patterns must include your zone". You "cannot create a Custom Domain ... on a zone you do not own", and a request "must exactly match" its hostname | A request "sent to a Cloudflare IP address for a non-existent Cloudflare domain" gives error 1001 | Routes, Custom Domains, Error 1001 |
The pattern is the same everywhere: the platform serves a hostname only after you have told it about that hostname. For a SaaS with many customer domains that is either an API integration per platform, or a different Host header.
What actually happens? A test you can run
On 28 October 2026 we sent each platform's public address a request for a hostname none of them knows, the way a proxy that keeps the customer's Host would:
# Vercel's A-record address, Netlify's load balancer, Lovable's edge IP
# (each published in the platform's own domain docs)
curl -si http://76.76.21.21/ -H "Host: careers.example-customer-test.com" | head -5
curl -si http://75.2.60.5/ -H "Host: careers.example-customer-test.com" | head -5
curl -si http://185.158.133.1/ -H "Host: careers.example-customer-test.com" | head -5What came back:
| Platform | Status | Telling detail |
|---|---|---|
| Vercel | 404 | X-Vercel-Error: DEPLOYMENT_NOT_FOUND, body "The deployment could not be found on Vercel." |
| Netlify | 404 | Server: Netlify, body "Not Found - Request ID: ..." |
| Lovable | 409 | Server: cloudflare, body "error code: 1001" |
Then the fix, in one line of proxy config. We ran Caddy 2.11.2 locally, pointed it at a site hosted on Vercel, and sent it the same unknown customer Host:
:8088 {
reverse_proxy https://nextjs.org
}curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8088/ -H "Host: careers.example-customer-test.com"
# 200Sending the same customer Host straight to that site returned 404. Through Caddy it returned 200, because since v2.11.0 Caddy sets Host to the upstream's hostname when proxying to an HTTPS upstream, (Caddy docs). The platform sees a hostname it knows. To tell your app which customer domain the visitor typed, the relay has to pass it in a header, shown in the next sections.
What are the ways around it?
| Option | How it works | Good when | What you take on |
|---|---|---|---|
| 1. Register every customer domain on the platform | Vercel's domains API (projectsAddProjectDomain), Netlify domain aliases, Lovable's per-domain connect, Cloudflare for SaaS custom hostnames with a Worker as fallback origin |
You will stay on one platform, and its limits fit (Vercel Hobby: 50 custom domains per project; Netlify: no more than 50 aliases recommended) | One integration per platform, the platform's verification flow, its per-domain limits, and moving every domain if you change host |
| 2. Run your own relay server | A proxy you operate terminates TLS for customer domains, rewrites Host to myapp.vercel.app, and forwards the customer's hostname in a header |
You already run servers and want full control | Certificates, on-demand TLS abuse limits, DNS checks, renewals, uptime of the relay itself |
| 3. Use a custom-domains proxy that sends your origin hostname | Same as option 2, but someone else runs the proxy | You don't want to run servers in front of a serverless app | Reading the tenant from a header and verifying it |
Option 1 has no extra hop and keeps the platform's own protections, so if it fits, start there. Options 2 and 3 are the same idea, and the rest of this post applies to both.
Apexly has an app setting for option 3, Host header. Set it to "Send your origin's hostname" and your origin receives its own hostname as Host (for example myapp.vercel.app), with the customer's hostname in X-Apexly-Host, so custom domains work on Vercel, Netlify, Lovable and Cloudflare Workers without a relay server. It is under Settings > Host header in the dashboard, or PATCH /v1/apps/current with {"origin_host_header": "origin"}, and it is available on every plan, including Free. X-Forwarded-Host also carries the customer hostname, but some hosts overwrite it with their own, so with the origin setting read X-Apexly-Host (changelog, 28 October 2026). Apexly passes the visitor's IP in X-Forwarded-For and always connects with your origin's own hostname in SNI. On Vercel, though, Vercel replaces X-Forwarded-For unless you're on its Enterprise Trusted Proxy, so your app sees Apexly's IP rather than the visitor's.
How do I run my own relay?
If you take option 2, a Caddy relay with on-demand TLS is short. The ask endpoint must answer 200 only for hostnames your customers have actually added, or anyone can point a domain at you and burn your Let's Encrypt rate limits. The details are in On-demand TLS for customer domains.
{
on_demand_tls {
ask http://localhost:5555/check
}
}
https:// {
tls {
on_demand
}
reverse_proxy https://myapp.vercel.app {
header_up X-Customer-Host {host}
}
}This validates with caddy validate on Caddy 2.11.2. On Caddy versions before 2.11.0, also add header_up Host {upstream_hostport} inside reverse_proxy, or Host stays the customer's hostname. Caddy passes the original hostname in X-Forwarded-Host too, but don't rely on it: on Vercel, x-forwarded-host "is identical to the host header" (Vercel request headers), so your app would see myapp.vercel.app. The custom X-Customer-Host header carries the customer hostname instead.
Two things the snippet does not do, and you must:
- Prove the request came through the relay. Your
myapp.vercel.appURL is public. Anyone can call it withX-Customer-Host: careers.acme.comand impersonate a tenant. Sign each forwarded request with a shared secret (for example an HMAC of a timestamp and the hostname) and reject requests that don't verify. - Keep DNS, certificates and renewals healthy. That is most of the work in the complete guide.
What changes in my app when Host is the platform hostname?
Anything that used Host to mean "the customer's domain" now gets myapp.vercel.app. Go through this list:
| Where | What breaks | Fix |
|---|---|---|
| Tenant lookup | Every request maps to the same tenant, or none | Look the tenant up by the forwarded customer hostname, never by Host |
| Trust | A direct request to your platform URL can claim any tenant | Accept the forwarded hostname only after the proxy's signature verifies |
| Absolute URLs and redirects | Links, emails, Location headers, sitemaps and canonical tags point visitors to myapp.vercel.app |
Build absolute URLs from the customer hostname. Test with curl -sI through the proxy and check Location |
| Cookies | A cookie with Domain=myapp.vercel.app is rejected by the browser on careers.acme.com |
Omit the Domain attribute so the cookie stays on the host the visitor sees |
| OAuth and magic links | Redirect URIs built from Host send users to the platform hostname | Use the customer hostname, and register it with your identity provider where required |
| CSRF and origin checks | Your framework compares the browser's Origin (customer domain) with Host (platform domain) and refuses |
Next.js: see the next section. Other frameworks: add the customer hostnames to the trusted-origin setting |
| Browser-only apps | Nothing, for code running in the browser: window.location.hostname is still careers.acme.com |
Only server-side code (API routes, functions, SSR) needs the header |
How do Next.js Server Actions work when Host differs from the customer domain?
Next.js protects Server Actions against CSRF. Its docs say it "compares the host in a request's Origin header against the app's own host, taken from x-forwarded-host or host, and rejects the action when the two differ" (Next.js serverActions docs).
Behind a proxy that sends your platform hostname, the browser's Origin is careers.acme.com while the server reports myapp.vercel.app. On Vercel, x-forwarded-host "is identical to the host header" (Vercel request headers), so it doesn't help. The action is rejected.
The fix is the allowedOrigins list. The key is still experimental.serverActions.allowedOrigins in the current docs (checked 3 November 2026 against Next.js 16.4.3):
// next.config.ts
import type { NextConfig } from "next";
// Hostnames your customers use, e.g. "careers.acme.com,help.globex.io".
// Load the list from your database or an env var at build time.
const customerHosts = (process.env.CUSTOMER_HOSTS ?? "")
.split(",")
.map((h) => h.trim().toLowerCase())
.filter(Boolean);
const nextConfig: NextConfig = {
experimental: {
serverActions: {
allowedOrigins: customerHosts,
},
},
};
export default nextConfig;This file type-checks against Next.js 16.4.3. Things to know:
- Entries are hosts, not URLs. Only the host of the Origin header is matched, with the port if the URL has one. Write
careers.acme.com, nothttps://careers.acme.com. - Wildcards are label-based.
*stands for exactly one label and**for one or more, and**only at the start.*.acme.commatchescareers.acme.combut notacme.com. Customer domains are all different, so you usually list each one. - It's config, not a runtime check. The docs don't describe a way to change the list while the app runs, so plan on a new deployment when the list changes.
- Route handlers are not affected. The check is for Server Actions.
Then read the tenant from the forwarded header, not from Host. With Apexly in front and the setting on, read the tenant from x-apexly-host, and verify X-Apexly-Signature and X-Apexly-Timestamp against that value, because the signature still covers the customer's hostname (Apexly Next.js guide).
// lib/customer-host.ts
import { headers } from "next/headers";
// The hostname the visitor typed (careers.acme.com),
// not the platform hostname in Host (myapp.vercel.app).
export async function customerHost(): Promise<string | null> {
const h = await headers();
const host = h.get("x-apexly-host");
if (!host) return null;
// Verify the signature headers for this host before trusting it.
return host.toLowerCase().split(":")[0];
}If you run your own relay, swap the header name for the one your relay sets, such as x-customer-host in the Caddy example above. Everything else about routing by hostname, from proxy.ts to cookies, is in Custom domains in a multi-tenant Next.js app.
What else should I check on each platform?
- Vercel. Vercel recommends connecting directly where possible. Its proxy guide lists the tradeoffs of any proxy in front: the Bot Protection managed ruleset "does not work when a reverse proxy is placed in front of Vercel", and your app may see the proxy's IP address instead of the visitor's. Verified Proxy Lite covers a list of named providers on all plans; self-hosted proxies need Verified Proxy Advanced, which is Enterprise. Also, Verified Proxy "does not block direct access to" your deployment, so the signature check in your app matters (Vercel proxy guide).
- Netlify. Pretty URLs, on by default, forward paths like
/aboutto/about/(Redirect options). Request a path without the trailing slash through your proxy and check that anyLocationheader keeps the visitor on the customer domain. - Lovable. If the app renders in the browser, it already sees the customer's domain in
window.location. Sign-in needs each domain in the auth redirect URLs. Lovable adds a connected domain automatically for its built-in backend; how that applies to domains that reach the app only through your own proxy is not stated, so test sign-in on a customer domain and add it under the auth settings if it fails. - Cloudflare Workers. Use your
workers.devhostname or a Custom Domain in your own zone as the origin hostname. If a customer's own zone is on Cloudflare and they proxy the record, you get a separate set of errors: see Customer domain on Cloudflare: fix 1014, 525 and SSL pending.
Checklist
- The proxy sends a hostname the platform knows as
Host(yourvercel.app,netlify.app,lovable.apporworkers.devname, or a domain you added yourself). - TLS to the platform uses that same hostname as SNI.
- The customer hostname arrives in one named header, and your app reads the tenant from it.
- The app accepts that header only when the proxy's signature verifies.
- Absolute URLs, redirects, cookies and OAuth redirect URIs use the customer hostname.
- Next.js: customer hostnames are in
experimental.serverActions.allowedOrigins, and a new domain triggers a deployment. - A request through the proxy with the customer's Host returns 200, while the same Host sent straight to the platform still fails (404 on Vercel and Netlify in our test), which proves the proxy is doing the work.
FAQ
Why does my customer's domain return 404 DEPLOYMENT_NOT_FOUND on Vercel?
Vercel picks the deployment from the hostname in the request. If that hostname, say careers.acme.com, isn't added to your Vercel project, Vercel has no deployment for it and answers 404 DEPLOYMENT_NOT_FOUND before your code runs. Add the domain to the project, or have your proxy send your own project hostname as Host.
Can I put a proxy in front of Netlify for customer domains?
Yes, but Netlify only serves domains assigned to a site. If the proxy forwards the customer's hostname as Host and that domain isn't on the site, the request fails (we got a 404 when we tested on 28 October 2026). Either add each domain as a domain alias (Netlify recommends no more than 50 per site) or forward your netlify.app hostname as Host.
How do customer domains reach a Cloudflare Worker?
Workers routes and Custom Domains only cover hostnames in a zone you own. For hostnames in your customers' zones, Cloudflare's answer is Cloudflare for SaaS custom hostnames with a Worker as the fallback origin, or a proxy that calls your Worker on its own hostname.
Which header holds the customer's hostname when Host is my platform hostname?
Whatever header your proxy sets for it. X-Forwarded-Host is not reliable, because some hosts overwrite it (on Vercel it is identical to Host), so use a dedicated header. Your app must read the tenant from that header and trust it only when the request is verified as coming from your proxy, because anyone can send any header to your platform hostname directly.
Why do Next.js Server Actions fail on a customer domain behind a proxy?
Next.js compares the browser's Origin host with x-forwarded-host or host and rejects the action if they differ. On Vercel both carry your vercel.app hostname, while Origin is the customer's domain. Add customer hostnames to experimental.serverActions.allowedOrigins in next.config.
Sources
All checked 3 November 2026.
- Vercel: Request headers, Using a proxy in front of Vercel, Reverse proxy servers and Vercel, Adding a custom domain, Multi-tenant custom domains, DEPLOYMENT_NOT_FOUND
- Netlify: Bring a domain to Netlify, Add a domain alias, Redirect options
- Lovable: Set up a custom domain
- Cloudflare: Workers routes, Workers Custom Domains, Workers as your fallback origin, Error 1001
- Next.js: serverActions (allowedOrigins)
- Caddy: reverse_proxy
- Apexly: Next.js guide, Changelog, 28 October 2026
If you'd rather not build this, Apexly does it in one API call. Free for 20 domains, no card.