Gradual deployments
Every deploy of a Worker uploads an immutable
version
— a snapshot of code, bindings, and compatibility settings — and a
deployment routes traffic across up to two versions by percentage.
By default alchemy deploys each new version at 100%. The version
prop unlocks the other shapes: gradual rollouts, canaries, and
traffic-free previews.
Roll out a deploy gradually
Section titled “Roll out a deploy gradually”Set version.traffic below 100 and the deploy splits traffic instead
of cutting over:
yield* Cloudflare.Worker("Api", { main: "./src/api.ts", version: { // 10% to the new version, 90% stays on the live one traffic: 10, // optional annotations, shown in the dashboard & `wrangler versions list` message: "new search index", tag: process.env.GITHUB_SHA, },});Each request is routed independently by percentage. Raise the number
and re-deploy to ramp; remove the prop (or set 100) to promote.
The first deploy of a script always goes to 100% — there is nothing
to split with. traffic: 0 uploads the version without deploying it
(the equivalent of wrangler versions upload).
Percentages replace, they never add
Section titled “Percentages replace, they never add”A deployment is a complete routing table, and each versioned deploy splits its new version against the stable side — the majority holder — of the current one:
version: { traffic: 10 } // deploys: v2 10%, v1 90%version: { traffic: 20 } // deploys: v3 20%, v1 80% — v2 drops outThe dropped version keeps existing as an upload, but it gets no traffic and a version override can no longer reach it. With unchanged code, v3 is identical to v2 and this is the ramp from 10 to 20; with changed code, you have swapped canaries. Either way there is one stable version and at most one canary per script, until you promote.
Ramp from CI
Section titled “Ramp from CI”Read the percentage from the environment so a ramp step is a workflow re-run, not a code change:
yield* Cloudflare.Worker("Api", { main: "./src/api.ts", version: { traffic: process.env.API_TRAFFIC ? Number(process.env.API_TRAFFIC) : 100, },});on: workflow_dispatch: inputs: traffic: description: "Traffic % for the new version" default: "100"
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: bun alchemy deploy env: API_TRAFFIC: ${{ inputs.traffic }}Deploy at 10, watch your metrics, re-run at 50, promote at the
default 100. Run every step from the same ref — a re-run from a
moving branch ships whatever landed since, which is a new rollout,
not a ramp.
Roll back
Section titled “Roll back”Rolling back is another deploy: re-deploy the previous code and it goes out as a new version at 100%. Versions are immutable snapshots, so the previous behavior comes back exactly — bindings and compatibility settings included.
Canary another stage’s Worker
Section titled “Canary another stage’s Worker”Set version.parent to upload this Worker’s code as a version of
another Worker’s script instead of creating a script of its own:
const parent = yield* Cloudflare.Worker.ref("Api", { stage: "prod" });
yield* Cloudflare.Worker("ApiCanary", { main: "./src/api.ts", version: { parent, traffic: 10 },});Destroying the canary restores 100% of traffic to the live version.
Preview versions
Section titled “Preview versions”With parent set and no traffic, the version receives no traffic —
it is reachable only at its preview URL. This is the PR-preview
workflow:
const parent = yield* Cloudflare.Worker.ref("Api", { stage: "staging" });
const preview = yield* Cloudflare.Worker("Api", { main: "./src/api.ts", version: { parent, message: `PR #${process.env.PR_NUMBER}` },});
// https://<alias>-<name>.<subdomain>.workers.dev — stable across// re-deploys, always serving the latest uploaded versionpreview.url;Alchemy derives the alias from the stack, stage, and logical id
(override with version.alias). The version carries its own bindings,
so a PR can bind its own KV namespaces, D1 databases, and secrets.
Preview URLs require the parent’s workers.dev subdomain (on by
default); workersDev: { enabled: false, previewsEnabled: true }
keeps previews working with the stable URL off.
Preview version or preview stage?
Section titled “Preview version or preview stage?”Deploying the stack to its own stage is also a preview — a fully independent one, with its own script and its own copies of every resource. A preview version rides the parent’s script, settings, and version history, and adds no scripts to the account. Use a stage to preview infrastructure changes; use a version to preview code.
Smoke test before shifting traffic
Section titled “Smoke test before shifting traffic”The Cloudflare-Workers-Version-Overrides request header pins a
request to a specific version in the current deployment:
const api = yield* Cloudflare.Worker("Api", { main: "./src/api.ts", // 1% — the smallest deployment an override can reach. traffic: 0 // stays out of the deployment entirely (preview URL only). version: { traffic: 1 },});
return { url: api.url.as<string>(), versionId: api.versionId };curl -s https://api.example.com/health \ -H 'Cloudflare-Workers-Version-Overrides: my-api="<versionId>"'The dictionary is keyed by the physical script name; an unknown
version or malformed header falls back to the percentages. Overrides
also apply to fetch()-based service binding calls — forward the
incoming request to keep cooperating Workers on matching versions. If
the smoke test fails, re-deploy the last good ref at the default 100
(Roll back).
Keep users on one version
Section titled “Keep users on one version”Percentages route each request independently, so one user can bounce
between versions mid-rollout. The Cloudflare-Workers-Version-Key
header pins them: Cloudflare hashes the key and assigns a version
deterministically from the percentages. Declare the key source with
version.affinity:
yield* Cloudflare.Worker("Api", { main: "./src/api.ts", domain: "api.example.com", version: { traffic: 10, // sticky by session cookie, falling back to sticky IP affinity: { cookie: "session_id", ip: true }, },});Alchemy provisions a transform rule on each zone the Worker serves on
(custom domains and routes), scoped to the Worker’s hostnames, that
fills the header from the cookie — and, for requests that don’t carry
the cookie yet, from the client IP. The rule updates in place as the
config changes and is removed when affinity is removed or the
Worker is destroyed. On a canary of another stage’s
Worker (version.parent), the rule
lands on the parent’s zones, pinning users across the canary split.
Besides cookie, the key can come from a request header (a tenant
or user id your edge already stamps), the client IP alone
({ ip: true }), or — for anything else, like a JWT claim via API
Shield — a raw
Rules-language
expression:
version: { traffic: 10, affinity: { key: `http.request.jwt.claims["<token-config-id>"][0]` },}Transform rules only see zone traffic; on the bare workers.dev URL,
clients send the header themselves:
curl -s https://api.example.com -H 'Cloudflare-Workers-Version-Key: user-123'Pinned users only move forward as percentages rise, never back.
For full control over the rule — custom match conditions, keys
composed from several fields, rules spanning Workers — drop down to a
manual Ruleset in the same phase, which is exactly what the sugar
manages for you:
const zone = yield* Cloudflare.Zone.Zone("Zone", { name: "example.com" });
yield* Cloudflare.Ruleset.Ruleset("VersionAffinity", { zone, phase: "http_request_late_transform", rules: [ { description: "Pin users to a Worker version by session cookie", expression: `len(http.request.cookies["session_id"]) > 0`, action: "rewrite", actionParameters: { headers: { "Cloudflare-Workers-Version-Key": { operation: "set", expression: `http.request.cookies["session_id"][0]`, }, }, }, }, ],});Verify which version served a request
Section titled “Verify which version served a request”The version_metadata binding exposes the running version’s id,
tag, and timestamp, tying every response, log, and error report to
a deploy:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export default Cloudflare.Worker( "Api", { main: import.meta.url }, Effect.gen(function* () { const versionMetadata = yield* Cloudflare.Workers.VersionMetadata();
return { fetch: Effect.gen(function* () { // `tag` is whatever version.tag was set to — e.g. the git SHA const { id, tag } = yield* versionMetadata; return yield* HttpServerResponse.json({ ok: true, version: id, tag }); }), }; }).pipe(Effect.provide(Cloudflare.Workers.VersionMetadataBinding)),);See Version metadata for the async-Worker variant.
What a version carries
Section titled “What a version carries”A version snapshots code, bindings, compatibility settings, and cache configuration. Script-level settings — routes, custom domains, crons, tags, observability, logpush, placement, limits, and the workers.dev subdomain — apply immediately to all versions: they are rejected on a version worker, and during a gradual rollout they keep their live values until the next full deploy.
Anything the Worker calls — Durable Objects, Workflows, service bindings — sits outside the version too, so both versions talk to it during the rollout. Ship those changes expand-and-contract:
- Deploy the new DO handler / Workflow / RPC method at 100%, alongside the old one.
- Ramp the caller gradually; the old version keeps calling the old path.
- After promotion, remove the old path.
The same goes for data: both versions share the same KV, D1, and DO storage, so schema changes must stay compatible until the rollout completes.
Platform limits to plan around:
- A deployment splits between at most two versions, and only the last 100 uploaded versions are deployable.
- Each Durable Object is assigned one version by the percentages — a single object never splits across versions. Class migrations cannot ride a rollout (deploy them at 100% first), and a version worker cannot host DO or Workflow classes — host them on the parent and reference them cross-script.
- Workers that implement Durable Objects do not get preview URLs.
- Workers Cache is per-version by
default, so every version starts cold;
crossVersionCache: trueshares it across versions.
Containers roll out by instance, not by request
Section titled “Containers roll out by instance, not by request”Containers shift gradually by replacing running instances with the new image in steps — there is no request-level split between two images:
export class Api extends Cloudflare.Container<Api>()("Api", { main: import.meta.url, instances: 4, maxInstances: 4, // replace 25% of instances per step, advancing as they come up // healthy; omit `rollout` to replace all instances at once rollout: { strategy: "rolling", stepPercentage: 25 },}) {}Alchemy creates the rollout automatically whenever the image or configuration changes. The fronting Worker cuts over immediately while instances roll, so keep the Worker-to-container protocol compatible across both images until the rollout completes. See Rollouts in the Containers guide.
Where next
Section titled “Where next”- Workers — the full Worker reference,
including the
versionprop and URLs. - Custom domains — serve the rollout from your own hostnames.
- Workers Cache — edge caching and the cross-version cache.
- Cloudflare’s gradual deployments docs — the underlying platform feature.