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:
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 {}; }),);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.
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.
Serve a hostname whose DNS is elsewhere
Section titled “Serve a hostname whose DNS is elsewhere”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.
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.
Split one hostname across Workers
Section titled “Split one hostname across Workers”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.
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 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.
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: