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 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:
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 {}; }),);Create a Zone
Section titled “Create a Zone”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());Adopt an existing Zone
Section titled “Adopt an existing Zone”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().
Attach a custom domain to a Worker
Section titled “Attach a custom domain to a Worker”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.
Route a hostname pattern to a Worker
Section titled “Route a hostname pattern to a Worker”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.
DNS records for non-Worker targets
Section titled “DNS records for non-Worker targets”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.
Disable Workers on a path
Section titled “Disable Workers on a path”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/*",});Control the workers.dev subdomain
Section titled “Control the workers.dev subdomain”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.devSubdomain 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.
Where next
Section titled “Where next”- Domains & DNS — the full domain-management surface: zone settings, DNSSEC, certificates, and more.
- Workers — the Worker resource itself and its binding system.
Reference: