Skip to content

Custom domains & routes

Every Worker gets a workers.dev URL by default, but production apps live on their own domain. That takes a few pieces: the Zone that holds your domain, the domain prop that attaches hostnames to a Worker, Routes for pattern-based dispatch, plain DNS records, and the account’s workers.dev subdomain.

All the snippets below run inside a Stack’s Effect.gen body:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "@alchemy.run/cloudflare";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"MyApp",
{ providers: Cloudflare.providers(), state: Cloudflare.state() },
Effect.gen(function* () {
// resources go here
return {};
}),
);
example.com · zonerequestGET https://api.example.com/v1/userscustom domainapi.example.comApiWorkerrouteshop.example.com/*no matchShopWorkerDNS recordapp.example.com → originno matchyour serverproxied origin
Every request to the zone arrives at Cloudflare first. A custom domain or a route decides which Worker answers it, and a hostname with neither goes to its DNS record's origin.

A Zone is a domain managed by Cloudflare. Declaring one gives you the zone’s identifiers and the name servers to point your registrar at:

const zone = yield* Cloudflare.Zone.Zone("MyZone", {
name: "example.com",
});
// zone.zoneId, zone.nameServers, zone.accountId, zone.status, ...

Zones default to retain on removal — destroying the stack does NOT delete the zone in Cloudflare. Opt in to actual deletion by wrapping the resource in destroy() from alchemy/RemovalPolicy:

import { destroy } from "alchemy/RemovalPolicy";
const zone = yield* Cloudflare.Zone.Zone("MyZone", {
name: "example.com",
}).pipe(destroy());

Most domains already exist in Cloudflare before Alchemy enters the picture. A zone carries no ownership markers, so the engine refuses to take over a pre-existing zone unless you opt in with adopt(true):

import { adopt } from "alchemy/AdoptPolicy";
const zone = yield* Cloudflare.Zone.Zone("MyZone", {
name: "example.com",
}).pipe(adopt(true));

Without adopt(true), deploying against an existing zone fails with a typed OwnedBySomeoneElse error instead of silently clobbering it. Once adopted, the zone behaves like any other managed resource — and it still retains on destroy unless you add destroy().

Pass domain — Cloudflare provisions the DNS record and certificate; the zone is inferred from the hostname and must already exist in the account:

const worker = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
domain: "api.example.com",
});
// worker.url === "https://api.example.com"

aliases also serve the Worker; redirects answer with a 301 (path and query preserved) to the canonical name, before the Worker runs — ideal for domain migrations:

const worker = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
domain: {
name: "api.example.com",
aliases: ["api-v2.example.com"],
redirects: ["api.old-example.com"], // 301 → https://api.example.com
},
workersDev: false,
});
// worker.url === "https://api.example.com"
// worker.urls === ["https://api.example.com", "https://api-v2.example.com"]
// worker.domain === { name: "api.example.com",
// aliases: ["api-v2.example.com"],
// redirects: ["api.old-example.com"] }

The canonical domain leads worker.urls (ahead of workers.dev), and redirect hostnames never appear there — they serve no content. Their redirect rules live in the zone’s http_request_dynamic_redirect phase, which runs before Workers.

Set previews: true so Worker Previews of this Worker are served at <preview-name>.api.example.com. That is a parent setting — enable it on the production Worker, not on the Preview Worker. Use a dedicated hostname (previews.example.com) if production already has subdomains, so the wildcard does not collide.

A native custom domain needs the hostname in a Cloudflare zone of the account. When the hostname’s DNS lives in Route 53 or Hetzner DNS, pass that host as domain.dns. The Worker then serves the hostname through Cloudflare for SaaS on a Cloudflare zone you own:

const worker = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
domain: {
name: "app.example.com",
zoneId: saasZone.zoneId, // a Cloudflare zone with Cloudflare for SaaS enabled
cnameTarget: fallback.origin, // the zone's FallbackOrigin hostname
dns: AWS.Route53.Adapter(),
},
});

Alchemy creates a custom hostname on the SaaS zone, publishes its ownership and certificate TXT records and a CNAME to cnameTarget in Route 53, and routes app.example.com/* on the SaaS zone to the Worker. Declare the zone’s Cloudflare.CustomHostname.FallbackOrigin once; several Workers can share it. redirects and previews need a native custom domain. See DNS Adapters for the full setup.

Routes are the classic alternative: a zone-level mapping from a URL pattern to a Worker script. Unlike the domain prop, a route can match paths (example.com/api/*), so different Workers can serve different parts of one hostname:

const worker = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
});
yield* Cloudflare.Workers.WorkerRoute("ApiRoute", {
zoneId: zone.zoneId,
pattern: "api.example.com/*",
script: worker.workerName,
});

A route only fires on hostnames that resolve through Cloudflare’s proxy, so pair it with a proxied DNS record. When the Worker is the only origin, an AAAA 100:: placeholder is the conventional choice:

yield* Cloudflare.DNS.Record("ApiPlaceholder", {
zoneId: zone.zoneId,
name: "api.example.com",
type: "AAAA",
content: "100::",
proxied: true,
});

Cloudflare enforces one route per pattern per zone, and routes carry no ownership markers — so if the route already exists, Alchemy reports it as unowned and refuses to take it over unless you opt in with adopt(true) (the same policy as zones):

import { adopt } from "alchemy/AdoptPolicy";
yield* Cloudflare.Workers.WorkerRoute("ApiRoute", {
zoneId: zone.zoneId,
pattern: "api.example.com/*",
script: worker.workerName,
}).pipe(adopt(true));

Both pattern and script are mutable — changing either updates the same physical route in place. Changing zoneId triggers a replacement.

Routes win over custom domains, so one Worker can own a hostname while others claim paths under it:

yield* Cloudflare.Worker("Home", {
main: "./src/home.ts",
domain: "api.example.com",
});
yield* Cloudflare.Worker("Orders", {
main: "./src/orders.ts",
routes: [{ pattern: "api.example.com/orders*" }],
});

alchemy dev applies the same routing locally. See Federated APIs for the full pattern.

Not everything on your zone is a Worker. Cloudflare.DNS.Record manages individual records — here a plain A record pointing at an external server:

yield* Cloudflare.DNS.Record("ApiA", {
zoneId: zone.zoneId,
name: "api.example.com",
type: "A",
content: "203.0.113.42",
ttl: 300,
});

Proxied records (orange-clouded) route through Cloudflare’s edge — proxied: true is only valid for A, AAAA, and CNAME records, and requires the automatic TTL. A common shape is a proxied CNAME at a Cloudflare Tunnel:

yield* Cloudflare.DNS.Record("AdminCname", {
zoneId: zone.zoneId,
name: "cluster-admin.example.com",
type: "CNAME",
content: `${tunnel.tunnelId}.cfargotunnel.com`,
proxied: true,
});

DNS records get the same adoption safety as routes: if a record with the same name, type and content (and priority, for MX and URI) already exists in the zone, Alchemy reports it as unowned and requires adopt(true) to take it over. A record with different content is left alone, and a new one is created beside it; only a CNAME, which a name holds just one of, is adopted whatever it points at. This protects hand-edited records — especially apex A/AAAA and email DKIM/SPF records the dashboard often manages — from being clobbered.

A route with no script opts matching requests out of Workers entirely. Use it to punch a hole in a broader wildcard route — for example, letting /assets/* bypass the Worker and hit the origin directly:

yield* Cloudflare.Workers.WorkerRoute("AssetsBypass", {
zoneId: zone.zoneId,
pattern: "example.com/assets/*",
});

Per Worker, the workersDev prop toggles the workers.dev URLs (it defaults to true). Once a custom domain serves production traffic, you may want to switch them off — the custom domain then becomes worker.url:

const worker = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
domain: "api.example.com",
workersDev: false,
});
// worker.url === "https://api.example.com"

The object form controls the stable URL and per-version preview URLs independently. With the stable URL off and previews on, each deploy’s preview URL (https://<version-prefix>-<name>.<subdomain>.workers.dev) becomes worker.url:

const worker = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
workersDev: { enabled: false, previewsEnabled: true },
});

Account-wide, the <subdomain> in https://<script>.<subdomain>.workers.dev is a singleton you can pin with the Subdomain resource:

const sub = yield* Cloudflare.Workers.Subdomain("Subdomain", {
subdomain: "my-team",
});
// Workers are now served from https://<script>.my-team.workers.dev

Subdomain names are globally unique across all Cloudflare accounts; claiming a taken name fails with a typed SubdomainAlreadyExists error. Destroy is capture-and-restore: the subdomain is renamed back to whatever the account had before Alchemy first managed it.

  • Domains & DNS — the full domain-management surface: zone settings, DNSSEC, certificates, and more.
  • Workers — the Worker resource itself and its binding system.

Reference: