---
title: "Custom domains on Vercel, Netlify, Lovable and Workers without a relay"
slug: custom-domains-vercel-netlify-lovable-workers
description: "Vercel, Netlify, Lovable and Cloudflare Workers route by hostname, so unknown customer domains fail. Send your hostname as Host, the customer's in a header."
date: 2026-11-03
updated: 2026-11-03
author: Vijay Tambe
authorRole: Founder, Apexly
keywords: [custom domain proxy vercel 404, deployment_not_found custom domain, netlify custom domain behind proxy, lovable custom domain reverse proxy, cloudflare workers customer custom domains, host header custom domains saas, x-forwarded-host vercel, next.js server actions allowedorigins custom domain]
faq:
  - q: "Why does my customer's domain return 404 DEPLOYMENT_NOT_FOUND on Vercel?"
    a: "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."
  - q: "Can I put a proxy in front of Netlify for customer domains?"
    a: "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."
  - q: "How do customer domains reach a Cloudflare Worker?"
    a: "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."
  - q: "Which header holds the customer's hostname when Host is my platform hostname?"
    a: "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."
  - q: "Why do Next.js Server Actions fail on a customer domain behind a proxy?"
    a: "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."
---

# Custom domains on Vercel, Netlify, Lovable and Workers without a relay

**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.

*Last updated 3 November 2026 · Vijay Tambe, founder of Apexly*

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](/blog/custom-domains-for-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)       → 404
```

Your 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](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)). 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](https://vercel.com/docs/headers/request-headers), [Proxy guide](https://vercel.com/kb/guide/can-i-use-a-proxy-on-top-of-my-vercel-deployment), [Error docs](https://vercel.com/docs/errors/DEPLOYMENT_NOT_FOUND) |
| **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](https://docs.netlify.com/manage/domains/configure-domains/bring-a-domain-to-netlify/), [Domain aliases](https://docs.netlify.com/manage/domains/configure-domains/add-a-domain-alias/), [Redirect options](https://docs.netlify.com/manage/routing/redirects/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](https://docs.lovable.dev/features/custom-domain) |
| **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](https://developers.cloudflare.com/workers/configuration/routing/routes/), [Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/), [Error 1001](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/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:

```bash
# 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 -5
```

What 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:

```caddyfile
:8088 {
	reverse_proxy https://nextjs.org
}
```

```bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8088/ -H "Host: careers.example-customer-test.com"
# 200
```

Sending 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](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)). 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](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/start/advanced-settings/worker-as-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](https://docs.apexly.dev/changelog#28-october-2026--origins-that-pick-the-app-by-hostname)). 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](https://vercel.com/docs/headers/request-headers#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](/blog/on-demand-tls-for-customer-domains).

```caddyfile
{
	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](https://vercel.com/docs/headers/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:

1. **Prove the request came through the relay.** Your `myapp.vercel.app` URL is public. Anyone can call it with `X-Customer-Host: careers.acme.com` and 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.
2. **Keep DNS, certificates and renewals healthy.** That is most of the work in the [complete guide](/blog/custom-domains-for-saas).

## 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](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions)).

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](https://vercel.com/docs/headers/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):

```ts
// 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`, not `https://careers.acme.com`.
- **Wildcards are label-based.** `*` stands for exactly one label and `**` for one or more, and `**` only at the start. `*.acme.com` matches `careers.acme.com` but not `acme.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](https://docs.apexly.dev/guides/nextjs)).

```ts
// 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](/blog/nextjs-multi-tenant-custom-domains).

## 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](https://vercel.com/kb/guide/can-i-use-a-proxy-on-top-of-my-vercel-deployment)).
- **Netlify.** Pretty URLs, on by default, forward paths like `/about` to `/about/` ([Redirect options](https://docs.netlify.com/manage/routing/redirects/redirect-options/)). Request a path without the trailing slash through your proxy and check that any `Location` header 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.dev` hostname 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](/blog/cloudflare-proxy-errors-custom-domains).

## Checklist

1. The proxy sends a hostname the platform knows as `Host` (your `vercel.app`, `netlify.app`, `lovable.app` or `workers.dev` name, or a domain you added yourself).
2. TLS to the platform uses that same hostname as SNI.
3. The customer hostname arrives in one named header, and your app reads the tenant from it.
4. The app accepts that header only when the proxy's signature verifies.
5. Absolute URLs, redirects, cookies and OAuth redirect URIs use the customer hostname.
6. Next.js: customer hostnames are in `experimental.serverActions.allowedOrigins`, and a new domain triggers a deployment.
7. 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](https://vercel.com/docs/headers/request-headers), [Using a proxy in front of Vercel](https://vercel.com/kb/guide/can-i-use-a-proxy-on-top-of-my-vercel-deployment), [Reverse proxy servers and Vercel](https://vercel.com/docs/security/reverse-proxy), [Adding a custom domain](https://vercel.com/docs/domains/working-with-domains/add-a-domain), [Multi-tenant custom domains](https://vercel.com/docs/multi-tenant/domain-management), [DEPLOYMENT_NOT_FOUND](https://vercel.com/docs/errors/DEPLOYMENT_NOT_FOUND)
- Netlify: [Bring a domain to Netlify](https://docs.netlify.com/manage/domains/configure-domains/bring-a-domain-to-netlify/), [Add a domain alias](https://docs.netlify.com/manage/domains/configure-domains/add-a-domain-alias/), [Redirect options](https://docs.netlify.com/manage/routing/redirects/redirect-options/)
- Lovable: [Set up a custom domain](https://docs.lovable.dev/features/custom-domain)
- Cloudflare: [Workers routes](https://developers.cloudflare.com/workers/configuration/routing/routes/), [Workers Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/), [Workers as your fallback origin](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/start/advanced-settings/worker-as-origin/), [Error 1001](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1001/)
- Next.js: [serverActions (allowedOrigins)](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions)
- Caddy: [reverse_proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
- Apexly: [Next.js guide](https://docs.apexly.dev/guides/nextjs), [Changelog, 28 October 2026](https://docs.apexly.dev/changelog#28-october-2026--origins-that-pick-the-app-by-hostname)

If you'd rather not build this, Apexly does it in one API call. Free for 20 domains, no card.
