1---
2title: Custom domains & routes
3description: Serve Workers from your own domain — create or adopt a Zone, attach custom domains, route hostname patterns, manage DNS records, and control the workers.dev subdomain.
4sidebar:
5 order: 0
6---
7
8Every Worker gets a `workers.dev` URL by default, but production apps
9live on their own domain. This guide covers the pieces involved: the
10Zone that holds your domain, the `domain` prop that attaches hostnames
11to a Worker, Routes for pattern-based dispatch, plain DNS records, and
12the account's `workers.dev` subdomain.
13
14All the snippets below run inside a Stack's `Effect.gen` body:
15
16```typescript
17// alchemy.run.ts
18import * as Alchemy from "alchemy";
19import * as Cloudflare from "alchemy/Cloudflare";
20import * as Effect from "effect/Effect";
21
22export default Alchemy.Stack(
23 "MyApp",
24 { providers: Cloudflare.providers(), state: Cloudflare.state() },
25 Effect.gen(function* () {
26 // resources go here
27 return {};
28 }),
29);
30```
31
32## Create a Zone
33
34A Zone is a domain managed by Cloudflare. Declaring one gives you the
35zone's identifiers and the name servers to point your registrar at:
36
37```typescript
38const zone = yield* Cloudflare.Zone.Zone("MyZone", {
39 name: "example.com",
40});
41// zone.zoneId, zone.nameServers, zone.accountId, zone.status, ...
42```
43
44Zones default to **retain** on removal — destroying the stack does NOT
45delete the zone in Cloudflare. Opt in to actual deletion by wrapping
46the resource in `destroy()` from `alchemy/RemovalPolicy`:
47
48```typescript
49import { destroy } from "alchemy/RemovalPolicy";
50
51const zone = yield* Cloudflare.Zone.Zone("MyZone", {
52 name: "example.com",
53}).pipe(destroy());
54```
55
56## Adopt an existing Zone
57
58Most domains already exist in Cloudflare before Alchemy enters the
59picture. A zone carries no ownership markers, so the engine refuses to
60take over a pre-existing zone unless you opt in with `adopt(true)`:
61
62```typescript
63import { adopt } from "alchemy/AdoptPolicy";
64
65const zone = yield* Cloudflare.Zone.Zone("MyZone", {
66 name: "example.com",
67}).pipe(adopt(true));
68```
69
70Without `adopt(true)`, deploying against an existing zone fails with a
71typed `OwnedBySomeoneElse` error instead of silently clobbering it.
72Once adopted, the zone behaves like any other managed resource — and it
73still retains on destroy unless you add `destroy()`.
74
75## Attach a custom domain to a Worker
76
77Pass `domain` — Cloudflare provisions the DNS record and certificate;
78the zone is inferred from the hostname and must already exist in the
79account:
80
81```typescript
82const worker = yield* Cloudflare.Worker("Api", {
83 main: "./src/api.ts",
84 domain: "api.example.com",
85});
86// worker.url === "https://api.example.com"
87```
88
89`aliases` also serve the Worker; `redirects` answer with a 301 (path
90and query preserved) to the canonical name, before the Worker runs —
91ideal for domain migrations:
92
93```typescript
94const worker = yield* Cloudflare.Worker("Api", {
95 main: "./src/api.ts",
96 domain: {
97 name: "api.example.com",
98 aliases: ["api-v2.example.com"],
99 redirects: ["api.old-example.com"], // 301 → https://api.example.com
100 },
101 workersDev: false,
102});
103// worker.url === "https://api.example.com"
104// worker.urls === ["https://api.example.com", "https://api-v2.example.com"]
105// worker.domain === { name: "api.example.com",
106// aliases: ["api-v2.example.com"],
107// redirects: ["api.old-example.com"] }
108```
109
110The canonical domain leads `worker.urls` (ahead of `workers.dev`), and
111redirect hostnames never appear there — they serve no content. Their
112redirect rules live in the zone's `http_request_dynamic_redirect`
113phase, which runs before Workers.
114
115Set `previews: true` so [Worker Previews](/cloudflare/compute/previews)
116of this Worker are served at `<preview-name>.api.example.com`. That is
117a parent setting — enable it on the production Worker, not on the
118Preview Worker. Use a dedicated hostname (`previews.example.com`) if
119production already has subdomains, so the wildcard does not collide.
120
121## Route a hostname pattern to a Worker
122
123Routes are the classic alternative: a zone-level mapping from a URL
124pattern to a Worker script. Unlike the `domain` prop, a route can match
125paths (`example.com/api/*`), so different Workers can serve different
126parts of one hostname:
127
128```typescript
129const worker = yield* Cloudflare.Worker("Api", {
130 main: "./src/api.ts",
131});
132
133yield* Cloudflare.Workers.WorkerRoute("ApiRoute", {
134 zoneId: zone.zoneId,
135 pattern: "api.example.com/*",
136 script: worker.workerName,
137});
138```
139
140A route only fires on hostnames that resolve through Cloudflare's
141proxy, so pair it with a proxied DNS record. When the Worker is the
142only origin, an `AAAA 100::` placeholder is the conventional choice:
143
144```typescript
145yield* Cloudflare.DNS.Record("ApiPlaceholder", {
146 zoneId: zone.zoneId,
147 name: "api.example.com",
148 type: "AAAA",
149 content: "100::",
150 proxied: true,
151});
152```
153
154Cloudflare enforces one route per pattern per zone, and routes carry no
155ownership markers — so if the route already exists, Alchemy reports it
156as unowned and refuses to take it over unless you opt in with
157`adopt(true)` (the same policy as zones):
158
159```typescript
160import { adopt } from "alchemy/AdoptPolicy";
161
162yield* Cloudflare.Workers.WorkerRoute("ApiRoute", {
163 zoneId: zone.zoneId,
164 pattern: "api.example.com/*",
165 script: worker.workerName,
166}).pipe(adopt(true));
167```
168
169Both `pattern` and `script` are mutable — changing either updates the
170same physical route in place. Changing `zoneId` triggers a replacement.
171
172## DNS records for non-Worker targets
173
174Not everything on your zone is a Worker. `Cloudflare.DNS.Record`
175manages individual records — here a plain A record pointing at an
176external server:
177
178```typescript
179yield* Cloudflare.DNS.Record("ApiA", {
180 zoneId: zone.zoneId,
181 name: "api.example.com",
182 type: "A",
183 content: "203.0.113.42",
184 ttl: 300,
185});
186```
187
188Proxied records (orange-clouded) route through Cloudflare's edge —
189`proxied: true` is only valid for `A`, `AAAA`, and `CNAME` records, and
190requires the automatic TTL. A common shape is a proxied CNAME at a
191Cloudflare Tunnel:
192
193```typescript
194yield* Cloudflare.DNS.Record("AdminCname", {
195 zoneId: zone.zoneId,
196 name: "cluster-admin.example.com",
197 type: "CNAME",
198 content: `${tunnel.tunnelId}.cfargotunnel.com`,
199 proxied: true,
200});
201```
202
203DNS records get the same adoption safety as routes: if a record with
204the same `(name, type)` already exists in the zone, Alchemy reports it
205as unowned and requires `adopt(true)` to take it over. This protects
206hand-edited records — especially apex `A`/`AAAA` and email
207DKIM/SPF records the dashboard often manages — from being clobbered.
208
209## Disable Workers on a path
210
211A route with no `script` opts matching requests out of Workers
212entirely. Use it to punch a hole in a broader wildcard route — for
213example, letting `/assets/*` bypass the Worker and hit the origin
214directly:
215
216```typescript
217yield* Cloudflare.Workers.WorkerRoute("AssetsBypass", {
218 zoneId: zone.zoneId,
219 pattern: "example.com/assets/*",
220});
221```
222
223## Control the workers.dev subdomain
224
225Per Worker, the `workersDev` prop toggles the `workers.dev` URLs (it
226defaults to `true`). Once a custom domain serves production traffic,
227you may want to switch them off — the custom domain then becomes
228`worker.url`:
229
230```typescript
231const worker = yield* Cloudflare.Worker("Api", {
232 main: "./src/api.ts",
233 domain: "api.example.com",
234 workersDev: false,
235});
236// worker.url === "https://api.example.com"
237```
238
239The object form controls the stable URL and per-version preview URLs
240independently. With the stable URL off and previews on, each deploy's
241preview URL (`https://<version-prefix>-<name>.<subdomain>.workers.dev`)
242becomes `worker.url`:
243
244```typescript
245const worker = yield* Cloudflare.Worker("Api", {
246 main: "./src/api.ts",
247 workersDev: { enabled: false, previewsEnabled: true },
248});
249```
250
251Account-wide, the `<subdomain>` in
252`https://<script>.<subdomain>.workers.dev` is a singleton you can pin
253with the `Subdomain` resource:
254
255```typescript
256const sub = yield* Cloudflare.Workers.Subdomain("Subdomain", {
257 subdomain: "my-team",
258});
259// Workers are now served from https://<script>.my-team.workers.dev
260```
261
262Subdomain names are globally unique across all Cloudflare accounts;
263claiming a taken name fails with a typed `SubdomainAlreadyExists`
264error. Destroy is capture-and-restore: the subdomain is renamed back to
265whatever the account had before Alchemy first managed it.
266
267:::caution
268Renaming the subdomain immediately changes the URL of **every**
269deployed Worker on the account that relies on `workers.dev`. Only
270manage this resource on accounts where that is acceptable.
271:::
272
273## Where next
274
275- [Domains & DNS](/cloudflare/networking/domains) — the full domain-management
276 surface: zone settings, DNSSEC, certificates, and more.
277- [Workers](/cloudflare/compute/workers) — the Worker resource itself and its
278 binding system.
279
280Reference:
281
282- [Zone API reference](/providers/cloudflare/zone/zone)
283- [WorkerRoute API reference](/providers/cloudflare/workers/workerroute)
284- [Record API reference](/providers/cloudflare/dns/record)
285- [Subdomain API reference](/providers/cloudflare/workers/subdomain)