Skip to content

Custom domains & routes

Every Worker gets a workers.dev URL by default, but production apps live on their own domain. This guide covers the pieces involved: the Zone that holds your domain, the domain prop that attaches a hostname 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/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 {};
}),
);

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().

The simplest way to serve a Worker on your domain is the domain prop. Cloudflare provisions the DNS record and certificate for you; the zone is inferred from the hostname, so it must already exist in the account:

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

domain also accepts an array. Custom domains come first in worker.domains (in the order you provided them), followed by the workers.dev URL when the subdomain is enabled — and worker.url is domains[0], so the custom domain wins:

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

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.

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) already exists in the zone, Alchemy reports it as unowned and requires adopt(true) to take it over. 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 url prop toggles the workers.dev URL (it defaults to true). Once a custom domain serves production traffic, you may want to switch the dev URL off:

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

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: