1---
2title: Gradual deployments
3description: Roll out Worker deploys incrementally — upload preview versions, canary a percentage of live traffic, ramp to 100%, pin users to a version during the rollout, and verify which version served each request.
4---
5
6Every deploy of a Worker uploads an immutable
7[version](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/),
8a snapshot of code, bindings, and compatibility settings. A
9*deployment* routes traffic across up to two versions by percentage.
10By default alchemy deploys each new version at 100%. The `version`
11prop unlocks the other shapes: gradual rollouts, canaries, and
12traffic-free version uploads. For branch and pull-request testing,
13use [Worker Previews](/cloudflare/compute/previews) (`preview.of`)
14instead of a zero-traffic version.
15
16## Roll out a deploy gradually
17
18Set `version.traffic` below 100 and the deploy splits traffic instead
19of cutting over:
20
21```typescript
22// alchemy.run.ts
23yield* Cloudflare.Worker("Api", {
24 main: "./src/api.ts",
25 version: {
26 // 10% to the new version, 90% stays on the live one
27 traffic: 10,
28 // optional annotations, shown in the dashboard & `wrangler versions list`
29 message: "new search index",
30 tag: process.env.GITHUB_SHA,
31 },
32});
33```
34
35Each request is routed independently by percentage. Raise the number
36and re-deploy to ramp; remove the prop (or set `100`) to promote.
37
38The first deploy of a script always goes to 100%, since there is nothing
39to split with. `traffic: 0` uploads the version without deploying it
40(the equivalent of `wrangler versions upload`).
41
42## Percentages replace, they never add
43
44A deployment is a complete routing table, and each versioned deploy
45splits its new version against the *stable* side of the current one,
46the majority holder:
47
48```typescript
49version: { traffic: 10 } // deploys: v2 10%, v1 90%
50version: { traffic: 20 } // deploys: v3 20%, v1 80% — v2 drops out
51```
52
53The dropped version keeps existing as an upload, but it gets no
54traffic and a version override can no longer reach it. With unchanged
55code, v3 is identical to v2 and this *is* the ramp from 10 to 20; with
56changed code, you have swapped canaries. Either way there is one
57stable version and at most one canary per script, until you promote.
58
59## Ramp from CI
60
61Read the percentage from the environment so a ramp step is a workflow
62re-run, not a code change:
63
64```typescript
65// alchemy.run.ts
66yield* Cloudflare.Worker("Api", {
67 main: "./src/api.ts",
68 version: {
69 traffic: process.env.API_TRAFFIC ? Number(process.env.API_TRAFFIC) : 100,
70 },
71});
72```
73
74```yaml
75# .github/workflows/deploy.yml
76on:
77 workflow_dispatch:
78 inputs:
79 traffic:
80 description: "Traffic % for the new version"
81 default: "100"
82
83jobs:
84 deploy:
85 runs-on: ubuntu-latest
86 steps:
87 - uses: actions/checkout@v4
88 - run: bun alchemy deploy
89 env:
90 API_TRAFFIC: ${{ inputs.traffic }}
91```
92
93Deploy at `10`, watch your metrics, re-run at `50`, promote at the
94default `100`. Run every step from the same ref. A re-run from a
95moving branch ships whatever landed since, which is a new rollout,
96not a ramp.
97
98## Roll back
99
100Rolling back is another deploy: re-deploy the previous code and it
101goes out as a new version at 100%. Versions are immutable snapshots,
102so the previous behavior comes back exactly, bindings and
103compatibility settings included.
104
105## Canary another stage's Worker
106
107Set `version.parent` to upload this Worker's code as a version of
108*another* Worker's script instead of creating a script of its own:
109
110```typescript
111const parent = yield* Cloudflare.Worker.ref("Api", { stage: "prod" });
112
113yield* Cloudflare.Worker("ApiCanary", {
114 main: "./src/api.ts",
115 version: { parent, traffic: 10 },
116});
117```
118
119Destroying the canary restores 100% of traffic to the live version.
120
121## Upload a version without routing traffic
122
123With `traffic: 0` the version is uploaded and reachable at its Version
124URL, but it is not in the live deployment. Use this to inspect a
125specific upload before a gradual rollout. For branch and pull-request
126testing — isolated Durable Objects, Preview-specific bindings, custom
127domain Preview URLs — use
128[Worker Previews](/cloudflare/compute/previews) (`preview.of`) instead.
129
130```typescript
131yield* Cloudflare.Worker("Api", {
132 main: "./src/api.ts",
133 version: { traffic: 0, tag: process.env.GITHUB_SHA },
134});
135```
136
137`version.parent` with no `traffic` still uploads a Version URL of
138another stage's Worker (the previous PR-preview path). Prefer
139`preview.of` for that job going forward.
140
141### Version, Preview, or stage?
142
143A Preview is a named copy of a Worker with isolated Durable Object
144state. A version rides the parent's script and version history. A
145stage is a fully independent stack. Use a stage to preview
146infrastructure changes; use a Preview to preview code; use a version
147to canary or inspect an upload. See
148[Worker Previews](/cloudflare/compute/previews).
149
150## Smoke test before shifting traffic
151
152The `Cloudflare-Workers-Version-Overrides` request header pins a
153request to a specific version in the current deployment:
154
155```typescript
156const api = yield* Cloudflare.Worker("Api", {
157 main: "./src/api.ts",
158 // 1% — the smallest deployment an override can reach. traffic: 0
159 // stays out of the deployment entirely (preview URL only).
160 version: { traffic: 1 },
161});
162
163return { url: api.url.as<string>(), versionId: api.versionId };
164```
165
166```sh
167curl -s https://api.example.com/health \
168 -H 'Cloudflare-Workers-Version-Overrides: my-api="<versionId>"'
169```
170
171The dictionary is keyed by the *physical script name*; an unknown
172version or malformed header falls back to the percentages. Overrides
173also apply to `fetch()`-based service binding calls. Forward the
174incoming request to keep cooperating Workers on matching versions. If
175the smoke test fails, re-deploy the last good ref at the default 100
176([Roll back](#roll-back)).
177
178## Keep users on one version
179
180Percentages route each request independently, so one user can bounce
181between versions mid-rollout. The `Cloudflare-Workers-Version-Key`
182header pins them: Cloudflare hashes the key and assigns a version
183deterministically from the percentages. Declare the key source with
184`version.affinity`:
185
186```typescript
187yield* Cloudflare.Worker("Api", {
188 main: "./src/api.ts",
189 domain: "api.example.com",
190 version: {
191 traffic: 10,
192 // sticky by session cookie, falling back to sticky IP
193 affinity: { cookie: "session_id", ip: true },
194 },
195});
196```
197
198Alchemy provisions a transform rule on each zone the Worker serves on
199(custom domains and routes), scoped to the Worker's hostnames, that
200fills the header from the cookie, and for requests that don't carry
201the cookie yet, from the client IP. The rule updates in place as the
202config changes and is removed when `affinity` is removed or the
203Worker is destroyed. On a [canary of another stage's
204Worker](#canary-another-stages-worker) (`version.parent`), the rule
205lands on the parent's zones, pinning users across the canary split.
206
207Besides `cookie`, the key can come from a request `header` (a tenant
208or user id your edge already stamps), the client IP alone
209(`{ ip: true }`), or, for anything else like a JWT claim via API
210Shield, a raw
211[Rules-language](https://developers.cloudflare.com/ruleset-engine/rules-language/)
212expression:
213
214```typescript
215version: {
216 traffic: 10,
217 affinity: { key: `http.request.jwt.claims["<token-config-id>"][0]` },
218}
219```
220
221Transform rules only see zone traffic; on the bare `workers.dev` URL,
222clients send the header themselves:
223
224```sh
225curl -s https://api.example.com -H 'Cloudflare-Workers-Version-Key: user-123'
226```
227
228Pinned users only move forward as percentages rise, never back.
229
230For full control over the rule, such as custom match conditions, keys
231composed from several fields, or rules spanning Workers, drop down to a
232manual `Ruleset` in the same phase, which is exactly what the sugar
233manages for you:
234
235```typescript
236const zone = yield* Cloudflare.Zone.Zone("Zone", { name: "example.com" });
237
238yield* Cloudflare.Ruleset.Ruleset("VersionAffinity", {
239 zone,
240 phase: "http_request_late_transform",
241 rules: [
242 {
243 description: "Pin users to a Worker version by session cookie",
244 expression: `len(http.request.cookies["session_id"]) > 0`,
245 action: "rewrite",
246 actionParameters: {
247 headers: {
248 "Cloudflare-Workers-Version-Key": {
249 operation: "set",
250 expression: `http.request.cookies["session_id"][0]`,
251 },
252 },
253 },
254 },
255 ],
256});
257```
258
259## Verify which version served a request
260
261The `version_metadata` binding exposes the running version's `id`,
262`tag`, and `timestamp`, tying every response, log, and error report to
263a deploy:
264
265```typescript
266// src/worker.ts
267import * as Cloudflare from "alchemy/Cloudflare";
268import * as Effect from "effect/Effect";
269import * as HttpServerResponse from "effect/http/HttpServerResponse";
270
271export default Cloudflare.Worker(
272 "Api",
273 { main: import.meta.url },
274 Effect.gen(function* () {
275 const versionMetadata = yield* Cloudflare.Workers.VersionMetadata();
276
277 return {
278 fetch: Effect.gen(function* () {
279 // `tag` is whatever version.tag was set to — e.g. the git SHA
280 const { id, tag } = yield* versionMetadata;
281 return yield* HttpServerResponse.json({ ok: true, version: id, tag });
282 }),
283 };
284 }).pipe(Effect.provide(Cloudflare.Workers.VersionMetadataBinding)),
285);
286```
287
288See [Version metadata](/cloudflare/compute/workers#versions)
289for the async-Worker variant.
290
291## What a version carries
292
293A version snapshots code, bindings, compatibility settings, and cache
294configuration. Script-level settings (routes, custom domains, crons,
295tags, observability, logpush, placement, limits, and the workers.dev
296subdomain) apply immediately to *all* versions: they are rejected on
297a version worker, and during a gradual rollout they keep their live
298values until the next full deploy.
299
300Anything the Worker *calls*, Durable Objects, Workflows, and service
301bindings, sits outside the version too, so both versions talk to it
302during the rollout. Ship those changes expand-and-contract:
303
3041. Deploy the new DO handler / Workflow / RPC method at 100%,
305 alongside the old one.
3062. Ramp the caller gradually; the old version keeps calling the old
307 path.
3083. After promotion, remove the old path.
309
310The same goes for data: both versions share the same KV, D1, and DO
311storage, so schema changes must stay compatible until the rollout
312completes.
313
314Platform limits to plan around:
315
316- A deployment splits between at most two versions, and only the last
317 100 uploaded versions are deployable.
318- Each Durable Object is assigned one version by the percentages,
319 so a single object never splits across versions. Class migrations
320 cannot ride a rollout (deploy them at 100% first), and a version
321 worker cannot host DO or Workflow classes. Host them on the parent
322 and reference them
323 [cross-script](/cloudflare/compute/cross-worker-durable-object).
324- Workers that implement Durable Objects do not get preview URLs.
325- [Workers Cache](/cloudflare/compute/cache) is per-version by
326 default, so every version starts cold; `crossVersionCache: true`
327 shares it across versions.
328
329## Containers roll out by instance, not by request
330
331[Containers](/cloudflare/compute/containers) shift gradually by
332replacing running *instances* with the new image in steps. There is
333no request-level split between two images:
334
335```typescript
336export class Api extends Cloudflare.Container<Api>()("Api", {
337 main: import.meta.url,
338 instances: 4,
339 maxInstances: 4,
340 // replace 25% of instances per step, advancing as they come up
341 // healthy; omit `rollout` to replace all instances at once
342 rollout: { strategy: "rolling", stepPercentage: 25 },
343}) {}
344```
345
346Alchemy creates the rollout automatically whenever the image or
347configuration changes. The fronting Worker cuts over immediately while
348instances roll, so keep the Worker-to-container protocol compatible
349across both images until the rollout completes. See
350[Rollouts](/cloudflare/compute/containers#rollouts) in the Containers
351guide.
352
353## Where next
354
355- [Workers](/cloudflare/compute/workers) — the full Worker reference,
356 including the `version` prop and URLs.
357- [Custom domains](/cloudflare/networking/custom-domains) — serve the
358 rollout from your own hostnames.
359- [Workers Cache](/cloudflare/compute/cache) — edge caching and the
360 cross-version cache.
361- [Cloudflare's gradual deployments docs](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/)
362 — the underlying platform feature.