Skip to content

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.

Set version.traffic below 100 and the deploy splits traffic instead of cutting over:

alchemy.run.ts
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).

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 out

The 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.

Read the percentage from the environment so a ramp step is a workflow re-run, not a code change:

alchemy.run.ts
yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
version: {
traffic: process.env.API_TRAFFIC ? Number(process.env.API_TRAFFIC) : 100,
},
});
.github/workflows/deploy.yml
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.

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.

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.

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 version
preview.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.

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.

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 };
Terminal window
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).

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:

Terminal window
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]`,
},
},
},
},
],
});

The version_metadata binding exposes the running version’s id, tag, and timestamp, tying every response, log, and error report to a deploy:

src/worker.ts
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.

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:

  1. Deploy the new DO handler / Workflow / RPC method at 100%, alongside the old one.
  2. Ramp the caller gradually; the old version keeps calling the old path.
  3. 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: true shares 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.