Every workspace VM starts from one OCI image — the box — containing SSH,
Docker-in-Docker, a baked service graph, and the base-owned in-place payload
updater (packages/box). The image carries a baked
payload for first boot. The control plane then pins the current scripts, Blitz
binaries, complete s6 service set, service implementations, four /etc files,
and Lody daemon as a separately published payload. This page covers both
artifacts, the ways to serve the image, and how upgrades behave.
Part of the self-host guide (step 9).
| Mode | BOX_IMAGE_REF |
BOX_IMAGE_TAG |
BOX_IMAGE_SHA256 |
Choose when |
|---|---|---|---|---|
| A — registry (recommended) | immutable image reference, e.g. ghcr.io/<owner>/blitz-box@sha256:<digest> |
"" |
"" |
You can publish the image publicly. Simplest, multi-arch, no R2 plumbing. |
| B — R2 archive | <your-worker-origin>/<key-prefix>/manifest.json — canary uses box-image/<releaseId>; a manual publish defaults to the legacy box-image |
the manifest's imageTag |
the concatenated-archive digest (equals totalSha256) |
The image must stay out of a registry. A prefix per release lets old and new archives coexist, and the archive is split into parts so its size does not matter. |
In mode B the archive lives in your blitz-box-images R2 bucket and is served
by your own Worker. Every box-image route is intentionally public — the
VM bootstrap fetches them with no credential, so anyone with your Worker URL
can download the archive. The same is true of mode A: the bootstrap runs
docker pull with no registry login, so the registry image must be publicly
pullable. Treat the box image as public in every mode.
| Deployment | Mode | Why |
|---|---|---|
| client prod | A — GHCR | release.yml builds and pushes the image on a v* tag, then pins the digest it just built. |
| canary | B — R2 archive | The image job in canary.yml automatically publishes changed base-image inputs under a versioned R2 prefix, then the deploy pins that release. |
The image pin has three deployment vars. BOX_IMAGE_REF is either the
immutable registry digest or the public R2 manifest URL. BOX_IMAGE_TAG and
BOX_IMAGE_SHA256 are required only for an R2 archive: they name the Docker
tag loaded from the archive and authenticate the reassembled bytes.
The in-place payload has two inseparable vars:
BOX_PAYLOAD_REFis the public, versionedbox-payload/<version>/manifest.jsonURL.BOX_PAYLOAD_VERSIONis the manifest's content hash and the desired version each non-held machine should run.
Set both from one validated release or set BOX_PAYLOAD_REF = "" to disable
payload delivery. Never mix the ref from one release with another version.
The publisher emits protocol 2 releases. A protocol 2 box refuses a protocol 1
release before downloading it, including for rollback.
Canary publishes this payload today. Client prod remains image-only until the
owner chooses an architecture contract for its amd64/arm64 fleet; see the
production plan.
This split is deliberate. Pushing to ghcr.io/blitzdotdev/blitz-box requires
write:packages, and the only credential that holds it is the
GITHUB_TOKEN inside release.yml, which runs only on a v* tag push.
A workspace credential cannot push: measured 2026-08-30, GH_PAT reads the
repository (HTTP 200) and pulls the image manifest (HTTP 200) but is refused
at POST /v2/blitzdotdev/blitz-box/blobs/uploads/ (HTTP 403), and the Blitz
GitHub App token has no package access at all. That is the boundary working,
not a fault to route around.
Cutting a v* tag to refresh the canary image is the wrong instrument: it
ships the platform to client prod, and that decision belongs to a human. So
canary carries its own image in its own account's R2 bucket, without touching
client prod.
On every push to main, canary.yml publishes or reuses both releases under
the canary environment:
- It checks out the merged tree, sets up Node, runs
npm ci, writes the canarywrangler.tomlfromCANARY_WRANGLER_TOML, and readsAPP_URLfrom that config. - The
imagejob buildsdaemon.tar.gzfrom the Dockerfile'sdaemonstage and builds the amd64 gateway binary with the same Go version as the Dockerfile.blitz-credis base-owned so a bad payload cannot disable the updater's bearer acquisition. It runsplan-box-payload.mjs --print-version --daemon <archive> --binaries <dir>; the resulting hash includes that exact daemon archive digest. - It computes a base release id from the Git object ids named by
scripts/lib/box-image-inputs.mjs. The full 64-character SHA-256 becomes<releaseId>, the image tag isblitz-box:<releaseId>, and the R2 prefix isbox-image/<releaseId>. - It requests
<APP_URL>/box-image/<releaseId>/manifest.json. A valid manifest with the expected image tag means that exact release is already published, so the job reuses itstotalSha256. A 404 means it must publish. An invalid manifest, a mismatched tag, any other HTTP status, or a network error fails the job instead of pretending the release is absent. - For an absent base release, it runs
docker build --platform linux/amd64 --build-arg BLITZ_PAYLOAD_VERSION=<planned-payload-version> -f packages/box/Dockerfile -t <imageTag> .. The daemon archive was built before the version was planned, so this baked stamp is the same daemon-inclusive version the payload publisher will use. Feature flags are deployment config and do not change image bytes. - It boots the image through its real
/initentrypoint withIMAGE=<imageTag> LODY_BOOT_ONLY=1 packages/box/test/smoke.sh. The smoke has a 180-second wall-clock readiness deadline. It seeds Lody in the container's updater state before start. It must pass before any archive object is published. - It runs
node packages/control-plane/scripts/publish-box-image.mjs --image <imageTag> --prefix <prefix> --app-url <APP_URL> --json publish.json. The publisher uploads every part beforemanifest.json, so a release is not visible until all its parts exist. - Only after the image job succeeds, the
payloadjob downloads the daemon archive and the gateway binary the image job built and uploaded as a workflow artifact, plans with--daemon, and refuses to continue unless its version equals the one used by the image job. It probes<APP_URL>/box-payload/<version>/manifest.json; a valid manifest for that version is reused, 404 is published with the same--daemon <archive>, and malformed content or any other response fails the deploy. The publisher uploadspayload.tar.gzanddaemon.tar.gzbeforemanifest.json. - The payload job exposes
refandversion. The deploy pins those alongside the image outputs and verifies that/versionreports the merged commit and expected box-image tag.
The base release id deliberately excludes payload-owned files, the gateway
binary, the daemon, and the repository env.defaults. It includes the
base-owned credential helper sources. The Dockerfile owns the box defaults and
writes a comment-only /etc/blitz/env.defaults for deployed hosts that still
pass it with --env-file. A payload-only merge therefore reuses the current image. Its baked stamp
names only the bytes in the baked payload and may name the payload current when
that base was built. That is informational boot state, not the rollout pin. A
fresh machine starts there and the updater converges it to
BOX_PAYLOAD_VERSION. When a base input changes, the new image is stamped with
the daemon-inclusive payload version published by the same run. The Dockerfile
and updater remain image inputs. The s6 service set belongs to the payload.
Lody release identity. The daemon is a payload input, not a base input.
vendor/lody, the reviewed adapter snapshots and the shared Lody build scripts
feed the Dockerfile's lody-build and daemon stages, and the payload version
hashes the resulting daemon.tar.gz. A pure Lody-input change therefore
publishes a new payload and reuses the base image. box-image-key.test.mjs
parses the Dockerfile and rejects any COPY source that is neither a base
input (BOX_IMAGE_INPUTS) nor a payload input (BOX_PAYLOAD_SOURCE_INPUTS
plus the payload-owned rootfs files). Follow docs/LODY-MERGE.md for the
upstream procedure.
The lody-build Docker stage runs the same
scripts/lody-build-package.mjs --source vendor/lody path used by the pair
gate. It overlays the five reviewed adapter snapshots, performs the frozen pnpm
install and CLI build, verifies the package manifest, and emits a tarball plus
BUILD.json. It also derives npm-shrinkwrap.json from the CLI production
graph in pnpm-lock.yaml. Exact registry URLs and integrities pin every runtime
package. A BuildKit cache mount retains the pnpm 10.20 store by Node line.
The architecture-neutral build stage does not key that cache by target platform.
The lockfile and build inputs still invalidate the build layer. Files outside
the Lody inputs, including packages/webapp, do not.
Adapter snapshots exclude generated dist/ and node_modules/ trees. The
lody-adapters-drift.test.mjs gate checks that every tracked Lody builder input
survives the ordered Dockerfile ignore rules.
The target-platform daemon stage extracts that tarball into the switchable
prefix /opt/blitz/lody/baked. It runs npm ci at the package root, where npm
enforces the shrinkwrap and checks every integrity. Lifecycle scripts stay
enabled for native dependencies. The package is
/opt/blitz/lody/current/lib/node_modules/lody, and s6 executes
/opt/blitz/lody/current/bin/lody start. The package's own stamp is
dist/BUILD.json. The stage also writes daemon-version (the upstream commit
and the dist digest from that stamp) and daemon-protocol-version beside the
prefix; the payload updater reads and reports both. The publisher exports the
whole prefix as daemon.tar.gz. Plan PR D will serve dist/BUILD.json over
/lody/build.
The canary environment's CLOUDFLARE_API_TOKEN must be able to write the
blitz-box-images bucket. A token that can deploy the Worker but cannot write
that R2 bucket makes either publishing job fail.
A canary release occupies these keys:
box-image/<releaseId>/manifest.jsonbox-image/<releaseId>/part-NNNfor every archive partbox-payload/<version>/manifest.jsonbox-payload/<version>/payload.tar.gzbox-payload/<version>/daemon.tar.gz
Old and new releases must coexist. Existing boxes and older deployed Worker
versions retain the full BOX_IMAGE_REF they received, so publishing a new
release must not change or remove the bytes at an older URL. The legacy
box-image/manifest.json and box-image/part-NNN routes remain served for
boxes already in the field; the automatic job never writes that slot.
A human does nothing to publish or pin the canary image now: merging to main
is sufficient. For an intentional manual R2 slot, pass
--prefix <key-prefix> to publish-box-image.mjs; pass
--prefix box-image explicitly only when replacing the legacy unversioned
slot.
Single-arch. A docker save archive carries one architecture, so the
canary archive is amd64. That matches the canary catalog, which offers x86
Hetzner types only. Adding an arm type means revisiting this.
Pushing a git tag v* runs .github/workflows/release.yml.
It builds blitz-box for linux/amd64 and linux/arm64.
It pushes the image to GHCR under your repository owner:
git tag v0.1.0
git push origin v0.1.0The workflow appends the immutable digests to the GitHub release notes. Then:
-
In the
blitz-boxpackage's settings on GitHub (Package settings → Danger zone → Change visibility), make it public. GHCR packages default to private on first push. -
Set the vars:
BOX_IMAGE_REF = "ghcr.io/<your-github-owner>/blitz-box@sha256:<digest-from-release-notes>" BOX_IMAGE_TAG = "" BOX_IMAGE_SHA256 = ""
-
npm run deploy -w packages/control-plane.
Pin by digest, not by tag: the VM pulls whatever the reference resolves to at boot, and a digest is the only immutable reference.
release.yml finishes the release for you when two repository secrets exist:
PROD_WRANGLER_TOML— the full contents of your deployment'swrangler.toml, in the comment-free generated form (the deploy cannot patch a TOML that holds comments).CLOUDFLARE_API_TOKEN— a token that can do what the local deploy does: Workers Scripts Edit, D1 Edit, and Workers R2 Storage Edit on the account.
With both set, every v* tag push builds the images and then deploys the
control plane with BOX_IMAGE_REF pinned to the digest it just pushed —
steps 2 and 3 above run in CI, and no manual release step remains. The CI
deploy is the same npm run deploy an operator runs: it applies D1
migrations and ships the worker code at the tag, so a tag is a full platform
release, not only an image swap. Worker secrets never touch CI — the deploy
only checks that they exist on the Worker.
Without the secrets the job skips with a notice, so forks and CI-only
checkouts are unaffected. The one manual step left is one-time: make the
blitz-box GHCR package public (step 1 above). The deploy job verifies the
digest is publicly pullable before deploying and fails otherwise — so on
your very first release, flip the visibility when that step fails, then
re-run the job. Nothing deploys an image workspaces cannot pull.
Build the image (see below), then publish it with the packaging script:
node packages/control-plane/scripts/publish-box-image.mjs --image blitz-box:localThe script runs docker save, gzips the archive, splits it into parts,
computes the per-part and total SHA-256 digests, writes the manifest.json,
uploads everything to the blitz-box-images bucket with wrangler r2 object put, and prints the exact BOX_IMAGE_* values to set. Copy them into
wrangler.toml and redeploy. Add --dry-run to build and verify the release
without uploading. Use --prefix <key-prefix> for a named manual slot; it
defaults to the legacy box-image layout. The remaining options (--archive,
--out, --bucket, --app-url, --part-size-mb, --json) are listed by
--help.
What lands in R2 under the chosen prefix:
<key-prefix>/manifest.json—{"parts":[{"name":"part-000","sha256":"<64 hex>"}, …],"totalSha256":"<64 hex>","imageTag":"blitz-box:<tag>"}<key-prefix>/<part-name>for every part.
A single-part archive is just a manifest with one part, so there is no
separate "small archive" mode to choose. The Worker still serves an
unsplit archive at GET /box-image for deployments configured that way
before the packaging script existed; nothing produces one now.
The manifest shape is a cross-runtime contract — the fixture corpus in
packages/schema/fixtures/box-image-manifest/ is its source of truth; do not
hand-edit one side.
Single-arch warning. A docker save archive carries one architecture.
An amd64 archive boots amd64 machines only. Keep the archive's architecture
matched to the machine types you offer; if you need both architectures, use
mode A, whose release build is multi-arch.
Open arm64 payload item. The payload identity and publisher currently derive from amd64 gateway bytes. Defining an architecture-qualified arm64 payload identity is intentionally out of scope for thin-image v1; do not point arm64 boxes at the amd64 payload pin until that contract is designed.
Use Node 22 and Go 1.26.5. The daemon archive must exist before the payload version is derived, and the exact same archive must go to the publisher. The build context is the repository root:
release_dir=$(mktemp -d)
daemon_archive="$release_dir/daemon.tar.gz"
node packages/control-plane/scripts/build-box-daemon.mjs \
--out "$daemon_archive"
payload_version=$(node packages/control-plane/scripts/plan-box-payload.mjs \
--print-version --daemon "$daemon_archive")
docker build --platform linux/amd64 \
--build-arg "BLITZ_PAYLOAD_VERSION=$payload_version" \
-f packages/box/Dockerfile -t blitz-box:local .
node packages/control-plane/scripts/publish-box-payload.mjs \
--daemon "$daemon_archive"That order is load-bearing: build daemon, derive with --daemon, stamp the
image, then publish with the same --daemon. Planning without it derives a
different version. publish-box-payload.mjs stages and verifies
payload.tar.gz, uploads it and daemon.tar.gz under
box-payload/<version>/, then uploads manifest.json last. It uses the
BOX_IMAGES R2 binding and APP_URL in
packages/control-plane/wrangler.toml; --dry-run --out <dir> keeps the
verified artifacts locally instead. Run
publish-box-image.mjs --image blitz-box:local as described in mode B when the
image also needs publishing.
On macOS, install and start Colima first — and size it up. The defaults (2 CPUs, 2 GiB memory) are too small for this build plus the inner Docker daemon; give it at least 4 CPUs and 8 GiB:
brew install colima docker
colima start --cpu 4 --memory 8
unset DOCKER_HOSTRun the image you built with the docker run command in the box README's
Install section, substituting
blitz-box:local for the registry reference. That command is the one canonical
copy; it carries the --privileged and long---mount reasoning with it.
packages/box/test/smoke.sh exercises the whole surface: s6 service graph,
key-only SSH, ttyd/tmux, files, ports, previews, DinD, and the
unprivileged degradation path. It builds one image and copies
BLITZ_LODY_SESSIONS=1 into the updater-owned feature file before boot. The
Lody checks use a 180-second wall-clock deadline with bounded host commands
while waiting for the supervised daemon and bridge, probe bridge health and
/lody/platform, require the packaged dist/BUILD.json, inspect the live
daemon environment and cgroup, and require its built-in-MCP-disable log. In CI
the smoke also requires memory, pids, and CPU delegation and proves that the
blitz user can create and remove a child under lody-sessions with
memory.max, pids.max, and cpu.max. The shipping CLI has no
credential-free adapter, so real session limits and cleanup are instead
exercised by the vendor sandbox suite in the daemon pair gate.
The same boot runs live payload steps E17–E20. They add and remove a service, refuse a release that removes the recovery floor, and flip Lody off and on. The steps keep unrelated service pids stable, post machine stats, and prove an established SSH session survives an sshd listener restart.
# Builds a throwaway blitz-box:smoke from this tree, then tests it:
packages/box/test/smoke.sh
# Tests an already-built image, and never builds:
IMAGE=blitz-box:local packages/box/test/smoke.shBuilding is the default on purpose. This is the only gate that runs the s6
service graph, so a run that silently adopted an existing tag could pass an
edit to rootfs/ or an s6 unit against an image that predates it. IMAGE=
skips the build, seeds the feature record into that container before boot, and
leaves the freshness of the tag yours to guarantee. Canary runs this form with
LODY_BOOT_ONLY=1 immediately after its build and before publishing any archive
parts, exiting after the Lody and cgroup checks rather than repeating the later
terminal/files/preview checks already owned by PR CI.
Point the BOX_IMAGE_* vars at the new image and redeploy the control plane.
New VMs use that pin immediately. An existing cloud VM moves only after an
owner or admin requests an update through the box-config v1 routes in
packages/control-plane/core/box-config.ts; the host updater emitted by
packages/control-plane/core/bootstrap.ts polls, replaces the container, and
reports the installed ref.
Updater state and downloaded releases live under /opt/blitz/payload in the
container image layer, not on the /var/lib/blitz state volume. Restarting the
same container keeps them. Recreating it starts from the baked payload, then
downloads the deployment pin on the first supervised tick five seconds after
boot.
BOX_PAYLOAD_REF and BOX_PAYLOAD_VERSION affect existing machines. Their
updaters poll box config, verify and apply the content-addressed release, and
report the running payload and daemon versions. Protocol 2 lets the payload add,
remove, or redefine services while four recovery definitions remain frozen.
An unacknowledged report is kept in updater state and retried before the next
poll does new work. To roll back, restore both vars from the previous
immutable protocol 2 payload release and redeploy. A lower version is an
ordinary target, so machines apply it on their next poll. Do not replace objects
under the current version; publish another content hash.
To apply a new pin immediately, stop the supervised updater, run one tick, then start the service again inside the box:
/command/s6-svc -d /run/service/payload
/usr/local/libexec/blitz-payload tick
/command/s6-svc -u /run/service/payloadThe command performs one complete tick. It shares
/run/blitz-payload.lock with the supervised updater and exits 75 when that
process holds the flock. The supervised launcher waits up to 300 seconds for
an operator tick; s6 retries if the lock remains held. The command also exits
nonzero when pending rollback recovery fails. After every successful
authenticated tick, the updater posts the used percentage for the filesystem
containing BLITZ_STATE_DIR. A stats failure is logged and never fails the
tick.
Hold one machine on its current payload with the session-authenticated, workspace-admin route:
PATCH /machines/<machine-id>
Content-Type: application/json
{"payloadHold":true}A hold makes that machine's box config answer payload: null; it does not
change the deployment-wide pin or undo the version already running. Send
{"payloadHold":false} to resume, at which point the machine converges to the
current deployment pin.
Read fleet state from the authenticated GET /workspaces/<workspace-id>
response. Each workspace.members[].machine reports id, payloadVersion,
daemonVersion, payloadOutcome, and payloadReportedAt; the versions name
what is running after the attempt, while a failure's detail names its target.
A null version means
that machine has not reported since the payload channel shipped. Compare each
payloadVersion with BOX_PAYLOAD_VERSION, and inspect the outcome/time before
declaring a rollout complete.
Image rollback starts by restoring the previous immutable BOX_IMAGE_* pins
and redeploying. New VMs then use them; request another box update for each
existing cloud VM that must move back. The in-place payload can be rolled back
independently.
This boot-time pinning is why the control plane gates some per-workspace
behavior on the VM's creation time (the BOX_IMAGE_*_SINCE_MS constants in
core/webapp-tickets.ts): a fleet always contains VMs on older images, and
the control plane must speak to each VM at the level its image understands.
For a fresh fork all your images postdate those cutoffs; the constants only
matter to fleets with history.