Operational and evergreen. This describes how this project's own hosted instances are deployed. If you run your own copy of BlitzOS, read SELF-HOST.md instead; it takes a fresh clone to a working deployment.
Nothing here names an account, a database, a zone, or a customer. Those are runtime facts. Every one of them is discoverable with the credentials the task requires, and this document says how to discover each. A runbook that hardcodes an identifier goes stale silently and leaks whatever it names.
| Role | What it is for | How it deploys |
|---|---|---|
| Canary | Dogfood. Every change lands here first. | Automatically, on every push to main. |
| Client prod | Real users. | Only from a v* git tag, and only after a human approves. |
They live in separate Cloudflare accounts, on purpose. Canary is in an
account owned by the maintainer; client prod is in the account that holds the
customer's data. The boundary is the reason a canary mistake cannot reach a
customer. A credential that reaches one must not reach the other, and an
Authentication error when a canary credential is pointed at client prod is
the boundary working, not a fault to route around.
Each deployment is one Cloudflare Worker with the same script name, one D1
database, and one R2 bucket. The R2 bucket holds box images, box payloads,
and user workspace files (core/files/dav.ts, core/files/folders.ts), so
the two deployments can never share one.
curl -s https://<origin>/version{ "commit": "...", "boxImageRef": "ghcr.io/.../blitz-box@sha256:...", "boxImageTag": "", "migration": "0028_....sql" }That is the authoritative answer to "which commit does this run". With a deployment's own credentials you can also ask Cloudflare directly:
npx wrangler whoami --config packages/control-plane/wrangler.toml
npx wrangler deployments list --config packages/control-plane/wrangler.tomlA v* tag is the only path. .github/workflows/release.yml holds the
credentials and does the whole job, in this order:
- Builds and pushes the box image for amd64 and arm64.
- Waits for a human to approve the
productionenvironment. - Writes the deployment config from a repository secret.
- Pins the box image digest it just built.
- Refuses to continue unless that image is publicly pullable. VM bootstraps pull with no registry login, so an unpullable digest would break every new workspace. Failing here leaves the deployment on its previous image.
- Runs
npm run deploy -w packages/control-plane, which checks the config, lists and applies D1 migrations, builds the webapp, and deploys. - Asks the origin which commit it now reports, and fails on disagreement.
So the image and the Worker always ship together, in that order. Never deploy the control plane alone for a release, and never publish an image by hand for one.
Client-prod payload publishing is not enabled yet. The payload publisher
produces amd64 Go binaries, while the GHCR box release is a multi-architecture
amd64/arm64 image. Pinning that archive deployment-wide would offer amd64 bytes
to an arm64 machine. Before enabling it, the owner must choose and implement
one of these contracts: architecture-keyed payload manifests selected by box
config, or an amd64-only production catalog. The intended workflow after that
decision is: build each daemon/binary archive, derive the matching content
version with --daemon, stamp the corresponding image, publish the payload to
client prod's own R2 bucket before deployment, then pass
BLITZ_DEPLOY_VAR_BOX_PAYLOAD_REF, BLITZ_DEPLOY_VAR_BOX_PAYLOAD_VERSION, and
BLITZ_DEPLOY_VAR_BOX_LODY_SESSIONS exactly as canary does. Until then
release.yml remains image-only and must not copy canary's payload URL; the
Cloudflare account boundary applies to artifacts too.
git tag -a v0.3.0 -m "..." && git push origin v0.3.0Nobody needs production credentials on a laptop, and no workspace, box, or agent should hold one.
.github/workflows/canary.yml runs on every push to main. Its gate job
checks that canary is configured. The image job first builds the daemon
archive and amd64 payload binaries, derives the daemon-inclusive payload
version, then derives the base-image release. It reuses a valid image archive
at that versioned R2 prefix or builds an absent one stamped with the planned
payload version and publishes it.
The dependent payload job independently rebuilds those deterministic inputs,
refuses a version different from the image job's plan, then probes
box-payload/<version>/manifest.json. It reuses a valid manifest for that
version or publishes payload.tar.gz, daemon.tar.gz, and finally the
manifest. The deploy pins the image ref/tag/digest and payload ref/version. It
also sets BOX_LODY_SESSIONS=1 beside the payload pins; only 1 enables that
box-config feature. The deploy verifies the merged commit and box-image tag
through /version.
Payload-only changes do not derive another base-image release. The image may
therefore retain the payload stamp baked when its base was built; that stamp is
boot state, while BOX_PAYLOAD_VERSION is desired state. A fresh machine boots
the baked copy and converges to the deployment pin. Dockerfile and updater
changes derive a new image. Service-set and topology changes publish a payload
and reuse the base image.
It queues rather than cancels concurrent runs. A cancelled deploy can leave migrations applied while the old Worker still serves.
Canary serves its box image and payload as public, versioned R2 artifacts out of its own account; client prod serves its image from a registry (mode A), pushed by the tag workflow. The canary jobs write only canary's R2 bucket and never touch the customer's account or GHCR. The full procedure and required R2 permission are in BOX-IMAGE.md. Lody upstream merges follow LODY-MERGE.md. A vendor-only merge ships as a new daemon-inclusive payload and reuses the base image.
Canary is one shared Worker and the last deploy wins. That is why it deploys
from main and not from a laptop: a branch deployed by hand replaces whatever
was there, and the next person debugs a build that matches no commit. This has
happened.
To reproduce the artifact build locally, follow
BOX-IMAGE.md's build order: build the daemon
archive, derive the payload version with --daemon, build the image with that
stamp, then publish the payload with the same daemon archive. Reversing or
omitting those inputs produces a different version.
Two environments, configured in repository settings:
| Environment | Protection | Used by |
|---|---|---|
canary |
none — deploys unattended | canary.yml |
production |
required reviewers | release.yml |
Secrets, by name only:
| Name | Where | Holds |
|---|---|---|
CANARY_WRANGLER_TOML |
canary environment |
The canary deployment's wrangler.toml. |
PROD_WRANGLER_TOML |
repository | The client-prod wrangler.toml. |
CLOUDFLARE_API_TOKEN |
both | A deploy token for the matching account. |
An environment secret overrides a repository secret of the same name, which is how each deployment gets a token for its own account.
A stored wrangler.toml must contain no comments. Wrangler's config
patcher refuses to write into a config that has any, and the deploy now writes
to it on every run, not only on the first.
You do not edit those two secrets when a route or a var is added. The deploy
fills a stale config in from wrangler.toml.example before it deploys, and
names every key it wrote in the log. assets.run_worker_first is generated
outright from core's route registrations. What is left in the secrets is what
only they can hold: account_id, the D1 database_id, the zone ids, APP_URL.
Edit them for a new account-specific identifier, and for nothing else.
Scope Account Resources to the one account that deployment lives in.
| Permission | Level | Needed by |
|---|---|---|
| Workers Scripts | Edit | deploy, secret list, rollback |
| D1 | Edit | d1 list, migrations apply |
| Workers R2 Storage | Edit | r2 bucket list |
| Account Settings | Read | whoami |
Editing a token's permissions does not change its value, so the secret needs no update afterwards.
The Worker also holds a secret named CLOUDFLARE_API_TOKEN, which it uses for
workspace tunnels. That is a different token in a different place. Do not
replace one with the other.
The Worker secrets listed in SELF-HOST.md already live in
Cloudflare. The deploy only checks that they exist, with wrangler secret list,
and refuses to deploy when one is missing. It never sets them, so GitHub never
sees them.
Run from the repository root. Each is documented in SELF-HOST.md.
| Command | Answers |
|---|---|
npm run config:check -w packages/control-plane |
Does this deployment's config still match wrangler.toml.example? |
npm run migrations:pending -w packages/control-plane |
What would a deploy apply? |
npm run rollback -w packages/control-plane |
What is the previous version? Prints a plan; needs --yes to act. |
config:check and the migration listing also run inside every deploy, before
anything contacts Cloudflare. Hosted image planning is part of the workflows:
canary derives and publishes content-addressed image and payload releases,
while the client-prod tag workflow always builds and pins its GHCR image.
config:check compares key paths and the entries of triggers.crons. It never
reads a value out of the deployment config, so it can run against a real one and
disclose nothing. It does not compare assets.run_worker_first: that array is
generated, and the deploy rewrites it on every run.
- The deploy applies them automatically. Never run
wrangler d1 migrations applyby hand. - Wrangler tracks applied migrations by filename. Renaming one makes the
deployment that applied the old name apply the new one as well. Check content
identity and reconcile the
d1_migrationsrows instead. - Before merging a migration, check its number against every other open pull
request and against
main. Duplicate prefixes resolve by filename sort; divergent content under one prefix does not. - Migrations are forward-only. Rolling the Worker back past one that dropped a column breaks the restored code's writes, because that code still targets the dropped column.
A Worker version carries its own vars, so a rollback restores the previous
BOX_IMAGE_REF and both BOX_PAYLOAD_* vars along with the previous code. New
workspaces return to the old box image with no second step, and existing canary
machines converge to the restored payload version. Do not roll back to a
Worker version whose payload pin is protocol 1 once protocol 2 boxes exist.
For a payload-only rollback, do not roll back Worker code. Take the previous immutable release's manifest URL and version from the last known-good canary workflow, then run:
BLITZ_DEPLOY_VAR_BOX_PAYLOAD_REF='<previous-protocol-2-manifest-url>' \
BLITZ_DEPLOY_VAR_BOX_PAYLOAD_VERSION='<previous-protocol-2-version>' \
npm run deploy -w packages/control-planeDowngrades use the normal updater path. A protocol 2 box refuses a protocol 1 release, so the rollback pin must name a protocol 2 release. Never replace the bytes under a published version.
A host image update recreates the box container. The new container starts from its baked payload and loses prior downloads, which are not on the state volume. Its first supervised tick starts five seconds after boot and downloads the pin again.
To apply the current pin by hand, stop the supervised updater, run one tick, then start it again:
docker exec blitz-box /command/s6-svc -d /run/service/payload
docker exec blitz-box /usr/local/libexec/blitz-payload tick
docker exec blitz-box /command/s6-svc -u /run/service/payloadblitz-payload tick exits 75 if the supervised updater still holds
/run/blitz-payload.lock. Wait, or stop the service before retrying. It exits
nonzero when pending rollback recovery fails.
To keep one machine on what it currently runs while the rest of the deployment advances, issue the session-authenticated workspace-admin request:
PATCH /machines/<machine-id>
Content-Type: application/json
{"payloadHold":true}The hold makes only that machine's box config omit the payload pin; it does not
roll the machine back. Send {"payloadHold":false} to resume the current pin.
Read a workspace fleet with authenticated GET /workspaces/<workspace-id>.
For each workspace.members[].machine, compare payloadVersion with the
deployment pin and inspect daemonVersion, payloadOutcome, and
payloadReportedAt. The two versions name what runs after the attempt. The
outcome names the result, and the report time shows when the control plane
accepted it. In the raw updater result, a failure's detail names the attempted
target. Null means the machine has not reported since payload updates shipped,
not that it is current.
D1 does not roll back. Read the migration list before you answer the plan.
There is no traffic ramp. A deploy goes to full traffic at once.
- A tag does not wait for CI.
release.ymltriggers on the tag alone and has no dependency onci.yml, so a red build can still reach the approval step. This is deliberate — the reviewer is the gate — but it means you must check CI on the commit yourself before you approve. It has already shipped a release whose commit had a failing test. - A bundle hash identifies the webapp build only.
/assets/index-*.jsis derived from webapp source, so a change to a route, a provider, orcore/bootstrap.tsleaves it identical. Two deployments running different commits can serve the same bundle name. Ask/versioninstead. - Base-image and payload changes have different reach. Dockerfile/base OS,
the payload updater,
blitz-cred, and the four Docker defaults require a new image and normally reach new workspaces only. Payload-owned scripts, gateway, the complete s6 service set, agent-rules bytes, four/etcfiles, and the Lody daemon update existing boxes in place. Webapp and control-plane changes ride the Worker and reach everything at once. - A core route absent from
assets.run_worker_firstis served the SPA shell with status 200, not the route. Nothing errors. The list is derived from core's route registrations now, andpackages/control-plane/test/route-prefixes.test.tsfails on every push if the generator stops covering a route — so this is caught before a deploy rather than by an operator running a check. After adding a route, runnpm run routes:sync -w packages/control-planeto refresh the generated copy inwrangler.toml.example; that same test names the command. - Wrangler 4 auto-loads the repository-root
.env. A narrow token there hijacks authentication over an interactive login and produces confusing 403s on account and D1 calls. Deploying from a scratch worktree avoids it entirely, because a worktree has no.env. - An interactive login may see more than one account. Keep
account_idpinned in every config so no call can land on the wrong one. - Adding a file under
core/touches three hand-maintained lists: the module manifest, and two module lists with length assertions in the test suite. (The route lists came off that tally: the managed worker's prefixes, the dev proxy andassets.run_worker_firstare all derived from the route registrations themselves.) RunBLITZDEV_MANAGED=1 npm testbefore pushing one; CI sets that variable, so a local run without it skips the gates that catch this. - A provider-specific line in the shared bootstrap kills every other
provider's box. See ../plans/PROVIDER-BOOTSTRAP.md;
an AWS-only mirror probe failed every Hetzner workspace under
pipefail. - No workspace or agent credential can push the box image to the registry.
write:packageson that package exists in exactly one place: theGITHUB_TOKENinsiderelease.yml, which runs only on av*tag push. Measured 2026-08-30, the workspace credentialGH_PATreads the repository and pulls the image manifest — both HTTP 200 — and is refused atPOST /v2/.../blobs/uploads/with HTTP 403; the GitHub App token has no package access at all (403,Resource not accessible by integration). That is the boundary working, not a fault to route around: the same tag that would push an image also ships client prod. Canary's automatic jobs publish their content-addressed image and payload releases through R2 instead. - The
wrangler.tomlin a working checkout is client prod's. The box image and payload publishers always pass--config packages/control-plane/wrangler.toml, so publishing a canary image or payload from a checkout carrying the client-prod config uploads it into the customer's bucket. Write a canary-scoped config fromwrangler.toml.examplefirst. That file is gitignored, so it never lands in a commit and never travels between the two accounts.
Most of these used to depend on discipline. Where a machine now enforces one, that is said.
- Never deploy a branch to canary. Enforced: canary deploys from
mainonly. Land the change first. - Never deploy client prod by hand, and never hold a credential that reaches
it. Enforced: the only path is a
v*tag, and the workflow holds the credentials. - Cutting a tag is a human decision. Enforced by the
productionenvironment's required reviewer. An agent may prepare a tag; a person approves the deploy. - Identify a deployment by
/version, never by a bundle hash. See the gotcha above for why the hash is not enough. - Verify the deploy; do not assume it. Partly enforced: both workflows
check
/versionafter deploying. For anything else a change shipped, fetch the live bundle and grep for a string the change introduced. A green deploy command proves upload, not content. - Let the canary workflow publish and pin its box image and payload. The image and payload jobs run before deploy, reuse only valid matching releases, and fail the run if planning, building, or publishing fails. See BOX-IMAGE.md.
- Never cut a
v*tag only to refresh canary. A tag ships client prod to real users. The automatic canary artifact jobs need no registry access; no workspace or agent credential can push there anyway. Theproductionreviewer remains the boundary between a tag and a customer deployment. - Say what you could not verify. A gate that could not run is not a gate that passed.