1---
2title: 2.0.0-beta.48 - Zones, Tunnels & Tokens
3date: 2026-06-01T09:00:00Z
4category: release
5excerpt: v2.0.0-beta.48 adds a `Cloudflare.Zone.Zone` resource, runtime Tunnel bindings, a `RateLimit` binding, and an `alchemy cloudflare create-token` CLI command for minting scoped API tokens.
6---
7
8`v2.0.0-beta.48` is a Cloudflare-heavy release: a managed
9**`Zone`** resource, runtime **Tunnel** bindings, a **`RateLimit`**
10binding, and a new **`create-token`** CLI command.
11
12## `Cloudflare.Zone.Zone`
13
14Manage a Cloudflare DNS zone as a resource:
15
16```typescript
17const zone = yield* Cloudflare.Zone.Zone("MyZone", {
18 name: "example.com",
19});
20```
21
22The full zone object is typed on the output — `zone.zoneId`,
23`zone.nameServers`, `zone.status`, and the rest.
24
25This creates the zone if it doesn't exist, and by default does
26**not** delete it on teardown. To import an existing zone, pipe
27`adopt()`:
28
29```typescript
30import * as AdoptPolicy from "alchemy/AdoptPolicy";
31
32yield* Cloudflare.Zone.Zone("MyZone", { name: "example.com" }).pipe(
33 AdoptPolicy.adopt(),
34);
35```
36
37To have it deleted on teardown, pipe `destroy()`:
38
39```typescript
40import * as RemovalPolicy from "alchemy/RemovalPolicy";
41
42yield* Cloudflare.Zone.Zone("MyZone", { name: "example.com" }).pipe(
43 RemovalPolicy.destroy(),
44);
45```
46
47This also adds `Resource(type, { defaultRemovalPolicy })` so a
48resource type can flip the engine's destroy-by-default (`Zone`
49uses `"retain"`), and `destroy()` / `retain()` now default to
50`true` when called with no argument.
51([#493](https://github.com/alchemy-run/alchemy/pull/493))
52
53## Tunnel bindings
54
55Bind a typed Tunnel client into a Worker and manage Cloudflare
56Tunnels at runtime. Bind it once in Init, then call methods from
57your handler:
58
59```typescript
60Effect.gen(function* () {
61 const tunnels = yield* Cloudflare.TunnelReadWrite.bind();
62
63 return {
64 fetch: Effect.gen(function* () {
65 const tunnel = yield* tunnels.create({ name: "on-demand-tunnel" });
66 yield* tunnels.putConfiguration(tunnel.id!, {
67 ingress: [
68 { hostname: "app.example.com", service: "http://localhost:3000" },
69 { service: "http_status:404" },
70 ],
71 });
72 const token = yield* tunnels.getToken(tunnel.id!);
73 return HttpServerResponse.json({ id: tunnel.id, token });
74 }),
75 };
76}).pipe(Effect.provide(Cloudflare.TunnelReadWriteLive));
77```
78
79Pick the surface you need:
80
81- `Cloudflare.Tunnel.ReadTunnel` — `get`, `list`, `getToken`, `getConfiguration`
82- `Cloudflare.Tunnel.WriteTunnel` — `create`, `update`, `delete`, `putConfiguration`
83- `Cloudflare.Tunnel.ReadWriteTunnel` — the full CRUD surface
84
85:::note[Under the covers]
86Binding a Tunnel client creates a scoped `AccountApiToken` for
87the Worker, attaches an allow policy with exactly the permission
88groups that surface needs, and binds the token's value into the
89Worker as a `secret_text`. The runtime client reads it back and
90makes the `cfd_tunnel` API calls for you. Roughly, the binding
91does this on your behalf:
92
93```typescript
94const token = yield* Cloudflare.ApiToken.AccountApiToken(`${worker}Token`);
95
96// attach exactly the permission groups for the chosen surface
97yield* token.bind("tunnel", {
98 policies: [
99 {
100 effect: "allow",
101 permissionGroups, // read scopes for TunnelRead, write for TunnelWrite
102 resources: { [`com.cloudflare.api.account.${accountId}`]: "*" },
103 },
104 ],
105});
106
107// inject token.value as secret_text; the client reads it at runtime
108```
109:::
110
111## `RateLimit` binding
112
113`Cloudflare.RateLimit({...})` counts arbitrary keys inside a
114Worker. The marker is dual-natured: a plain data structure in the
115`env` position **and** yieldable inside an effect-native Worker.
116
117In an effect-native Worker, yielding it attaches the binding
118**and** returns the runtime client in one step:
119
120```typescript
121Effect.gen(function* () {
122 const throttle = yield* Cloudflare.RateLimit({
123 namespaceId: 1001,
124 simple: { limit: 2, period: 10 },
125 });
126
127 return {
128 fetch: Effect.gen(function* () {
129 const { success } = yield* throttle.limit({ key: "ip" });
130 return HttpServerResponse.text(success ? "ok" : "rate limited");
131 }),
132 };
133}).pipe(Effect.provide(Cloudflare.RateLimitBindingLive));
134```
135
136Or declare it on `env` for an async (non-Effect) Worker, where
137`InferEnv` maps it to the native `cf.RateLimit`:
138
139```typescript
140export const Worker = Cloudflare.Worker("Worker", {
141 main: "./src/worker.ts",
142 env: {
143 THROTTLE: Cloudflare.RateLimit({
144 namespaceId: 1001,
145 simple: { limit: 2, period: 10 },
146 }),
147 },
148});
149```
150
151Thanks **[Alex](https://github.com/agcty)** for the
152contribution.
153([#238](https://github.com/alchemy-run/alchemy/pull/238))
154
155## `alchemy cloudflare create-token`
156
157Mint a Cloudflare API token from the terminal — e.g. to produce a
158`CLOUDFLARE_API_TOKEN` for CI or local deploys:
159
160```sh
161# curated permissions for typical Alchemy deploys
162alchemy cloudflare create-token --name ci-token
163
164# full-access "superuser" token (all permission groups)
165alchemy cloudflare create-token --all-permissions --name admin
166```
167
168It's standalone (no auth profile) and the token is never stored.
169Permission groups are resolved **live** from the account so IDs
170are always valid, and the minted token is verified via
171`/user/tokens/verify`. Because Cloudflare only grants permissions
172the authenticating credential holds, it authenticates with the
173account's Global API Key (`CLOUDFLARE_API_KEY` / `CLOUDFLARE_EMAIL`,
174or prompted). See the
175[CLI guide](/cli/cloudflare) for the full command.
176([#496](https://github.com/alchemy-run/alchemy/pull/496))
177
178## Also in this release
179
180- **`Cloudflare.BrowserRendering` renamed to `Cloudflare.Browser`** —
181 along with the matching binding/client/error exports
182 ([#503](https://github.com/alchemy-run/alchemy/pull/503)).
183- **Yieldable binding markers** — `Browser`, `Images`, and
184 `DynamicWorkerLoader` can be `yield*`ed directly inside an
185 effect-native Worker to attach the binding and get the client in
186 one step
187 ([#498](https://github.com/alchemy-run/alchemy/pull/498),
188 [#501](https://github.com/alchemy-run/alchemy/pull/501),
189 [#505](https://github.com/alchemy-run/alchemy/pull/505)).
190- **Providers docs refocused** — a new Bindings section and the
191 Providers page now centers on the lifecycle interface
192 ([#502](https://github.com/alchemy-run/alchemy/pull/502)).
193- **`Zone` leans on error tags** — distilled bumped to `0.22.3`
194 for the `ZoneAlreadyExists` tag, replacing brittle
195 error-message matching; the same distilled bump widened
196 generated enums to open unions, narrowed back at the API
197 boundary across several providers
198 ([#507](https://github.com/alchemy-run/alchemy/pull/507)).
199
200## Where to go next
201
202- [CLI guide](/cli)
203- [CHANGELOG](https://github.com/alchemy-run/alchemy/blob/main/CHANGELOG.md#v200-beta48)
204- [Compare v2.0.0-beta.47 → v2.0.0-beta.48](https://github.com/alchemy-run/alchemy/compare/v2.0.0-beta.47...v2.0.0-beta.48)