| 1 | --- |
| 2 | title: Gradual deployments |
| 3 | description: 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 | |
| 6 | Every deploy of a Worker uploads an immutable |
| 7 | [version](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/), |
| 8 | a snapshot of code, bindings, and compatibility settings. A |
| 9 | *deployment* routes traffic across up to two versions by percentage. |
| 10 | By default alchemy deploys each new version at 100%. The `version` |
| 11 | prop unlocks the other shapes: gradual rollouts, canaries, and |
| 12 | traffic-free version uploads. For branch and pull-request testing, |
| 13 | use [Worker Previews](/cloudflare/compute/previews) (`preview.of`) |
| 14 | instead of a zero-traffic version. |
| 15 | |
| 16 | ## Roll out a deploy gradually |
| 17 | |
| 18 | Set `version.traffic` below 100 and the deploy splits traffic instead |
| 19 | of cutting over: |
| 20 | |
| 21 | ```typescript |
| 22 | // alchemy.run.ts |
| 23 | yield* 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 | |
| 35 | Each request is routed independently by percentage. Raise the number |
| 36 | and re-deploy to ramp; remove the prop (or set `100`) to promote. |
| 37 | |
| 38 | The first deploy of a script always goes to 100%, since there is nothing |
| 39 | to 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 | |
| 44 | A deployment is a complete routing table, and each versioned deploy |
| 45 | splits its new version against the *stable* side of the current one, |
| 46 | the majority holder: |
| 47 | |
| 48 | ```typescript |
| 49 | version: { traffic: 10 } // deploys: v2 10%, v1 90% |
| 50 | version: { traffic: 20 } // deploys: v3 20%, v1 80% — v2 drops out |
| 51 | ``` |
| 52 | |
| 53 | The dropped version keeps existing as an upload, but it gets no |
| 54 | traffic and a version override can no longer reach it. With unchanged |
| 55 | code, v3 is identical to v2 and this *is* the ramp from 10 to 20; with |
| 56 | changed code, you have swapped canaries. Either way there is one |
| 57 | stable version and at most one canary per script, until you promote. |
| 58 | |
| 59 | ## Ramp from CI |
| 60 | |
| 61 | Read the percentage from the environment so a ramp step is a workflow |
| 62 | re-run, not a code change: |
| 63 | |
| 64 | ```typescript |
| 65 | // alchemy.run.ts |
| 66 | yield* 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 |
| 76 | on: |
| 77 | workflow_dispatch: |
| 78 | inputs: |
| 79 | traffic: |
| 80 | description: "Traffic % for the new version" |
| 81 | default: "100" |
| 82 | |
| 83 | jobs: |
| 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 | |
| 93 | Deploy at `10`, watch your metrics, re-run at `50`, promote at the |
| 94 | default `100`. Run every step from the same ref. A re-run from a |
| 95 | moving branch ships whatever landed since, which is a new rollout, |
| 96 | not a ramp. |
| 97 | |
| 98 | ## Roll back |
| 99 | |
| 100 | Rolling back is another deploy: re-deploy the previous code and it |
| 101 | goes out as a new version at 100%. Versions are immutable snapshots, |
| 102 | so the previous behavior comes back exactly, bindings and |
| 103 | compatibility settings included. |
| 104 | |
| 105 | ## Canary another stage's Worker |
| 106 | |
| 107 | Set `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 |
| 111 | const parent = yield* Cloudflare.Worker.ref("Api", { stage: "prod" }); |
| 112 | |
| 113 | yield* Cloudflare.Worker("ApiCanary", { |
| 114 | main: "./src/api.ts", |
| 115 | version: { parent, traffic: 10 }, |
| 116 | }); |
| 117 | ``` |
| 118 | |
| 119 | Destroying the canary restores 100% of traffic to the live version. |
| 120 | |
| 121 | ## Upload a version without routing traffic |
| 122 | |
| 123 | With `traffic: 0` the version is uploaded and reachable at its Version |
| 124 | URL, but it is not in the live deployment. Use this to inspect a |
| 125 | specific upload before a gradual rollout. For branch and pull-request |
| 126 | testing — isolated Durable Objects, Preview-specific bindings, custom |
| 127 | domain Preview URLs — use |
| 128 | [Worker Previews](/cloudflare/compute/previews) (`preview.of`) instead. |
| 129 | |
| 130 | ```typescript |
| 131 | yield* 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 |
| 138 | another stage's Worker (the previous PR-preview path). Prefer |
| 139 | `preview.of` for that job going forward. |
| 140 | |
| 141 | ### Version, Preview, or stage? |
| 142 | |
| 143 | A Preview is a named copy of a Worker with isolated Durable Object |
| 144 | state. A version rides the parent's script and version history. A |
| 145 | stage is a fully independent stack. Use a stage to preview |
| 146 | infrastructure changes; use a Preview to preview code; use a version |
| 147 | to canary or inspect an upload. See |
| 148 | [Worker Previews](/cloudflare/compute/previews). |
| 149 | |
| 150 | ## Smoke test before shifting traffic |
| 151 | |
| 152 | The `Cloudflare-Workers-Version-Overrides` request header pins a |
| 153 | request to a specific version in the current deployment: |
| 154 | |
| 155 | ```typescript |
| 156 | const 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 | |
| 163 | return { url: api.url.as<string>(), versionId: api.versionId }; |
| 164 | ``` |
| 165 | |
| 166 | ```sh |
| 167 | curl -s https://api.example.com/health \ |
| 168 | -H 'Cloudflare-Workers-Version-Overrides: my-api="<versionId>"' |
| 169 | ``` |
| 170 | |
| 171 | The dictionary is keyed by the *physical script name*; an unknown |
| 172 | version or malformed header falls back to the percentages. Overrides |
| 173 | also apply to `fetch()`-based service binding calls. Forward the |
| 174 | incoming request to keep cooperating Workers on matching versions. If |
| 175 | the 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 | |
| 180 | Percentages route each request independently, so one user can bounce |
| 181 | between versions mid-rollout. The `Cloudflare-Workers-Version-Key` |
| 182 | header pins them: Cloudflare hashes the key and assigns a version |
| 183 | deterministically from the percentages. Declare the key source with |
| 184 | `version.affinity`: |
| 185 | |
| 186 | ```typescript |
| 187 | yield* 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 | |
| 198 | Alchemy provisions a transform rule on each zone the Worker serves on |
| 199 | (custom domains and routes), scoped to the Worker's hostnames, that |
| 200 | fills the header from the cookie, and for requests that don't carry |
| 201 | the cookie yet, from the client IP. The rule updates in place as the |
| 202 | config changes and is removed when `affinity` is removed or the |
| 203 | Worker is destroyed. On a [canary of another stage's |
| 204 | Worker](#canary-another-stages-worker) (`version.parent`), the rule |
| 205 | lands on the parent's zones, pinning users across the canary split. |
| 206 | |
| 207 | Besides `cookie`, the key can come from a request `header` (a tenant |
| 208 | or user id your edge already stamps), the client IP alone |
| 209 | (`{ ip: true }`), or, for anything else like a JWT claim via API |
| 210 | Shield, a raw |
| 211 | [Rules-language](https://developers.cloudflare.com/ruleset-engine/rules-language/) |
| 212 | expression: |
| 213 | |
| 214 | ```typescript |
| 215 | version: { |
| 216 | traffic: 10, |
| 217 | affinity: { key: `http.request.jwt.claims["<token-config-id>"][0]` }, |
| 218 | } |
| 219 | ``` |
| 220 | |
| 221 | Transform rules only see zone traffic; on the bare `workers.dev` URL, |
| 222 | clients send the header themselves: |
| 223 | |
| 224 | ```sh |
| 225 | curl -s https://api.example.com -H 'Cloudflare-Workers-Version-Key: user-123' |
| 226 | ``` |
| 227 | |
| 228 | Pinned users only move forward as percentages rise, never back. |
| 229 | |
| 230 | For full control over the rule, such as custom match conditions, keys |
| 231 | composed from several fields, or rules spanning Workers, drop down to a |
| 232 | manual `Ruleset` in the same phase, which is exactly what the sugar |
| 233 | manages for you: |
| 234 | |
| 235 | ```typescript |
| 236 | const zone = yield* Cloudflare.Zone.Zone("Zone", { name: "example.com" }); |
| 237 | |
| 238 | yield* 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 | |
| 261 | The `version_metadata` binding exposes the running version's `id`, |
| 262 | `tag`, and `timestamp`, tying every response, log, and error report to |
| 263 | a deploy: |
| 264 | |
| 265 | ```typescript |
| 266 | // src/worker.ts |
| 267 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 268 | import * as Effect from "effect/Effect"; |
| 269 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 270 | |
| 271 | export 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 | |
| 288 | See [Version metadata](/cloudflare/compute/workers#versions) |
| 289 | for the async-Worker variant. |
| 290 | |
| 291 | ## What a version carries |
| 292 | |
| 293 | A version snapshots code, bindings, compatibility settings, and cache |
| 294 | configuration. Script-level settings (routes, custom domains, crons, |
| 295 | tags, observability, logpush, placement, limits, and the workers.dev |
| 296 | subdomain) apply immediately to *all* versions: they are rejected on |
| 297 | a version worker, and during a gradual rollout they keep their live |
| 298 | values until the next full deploy. |
| 299 | |
| 300 | Anything the Worker *calls*, Durable Objects, Workflows, and service |
| 301 | bindings, sits outside the version too, so both versions talk to it |
| 302 | during the rollout. Ship those changes expand-and-contract: |
| 303 | |
| 304 | 1. Deploy the new DO handler / Workflow / RPC method at 100%, |
| 305 | alongside the old one. |
| 306 | 2. Ramp the caller gradually; the old version keeps calling the old |
| 307 | path. |
| 308 | 3. After promotion, remove the old path. |
| 309 | |
| 310 | The same goes for data: both versions share the same KV, D1, and DO |
| 311 | storage, so schema changes must stay compatible until the rollout |
| 312 | completes. |
| 313 | |
| 314 | Platform 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 |
| 332 | replacing running *instances* with the new image in steps. There is |
| 333 | no request-level split between two images: |
| 334 | |
| 335 | ```typescript |
| 336 | export 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 | |
| 346 | Alchemy creates the rollout automatically whenever the image or |
| 347 | configuration changes. The fronting Worker cuts over immediately while |
| 348 | instances roll, so keep the Worker-to-container protocol compatible |
| 349 | across both images until the rollout completes. See |
| 350 | [Rollouts](/cloudflare/compute/containers#rollouts) in the Containers |
| 351 | guide. |
| 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. |