learn / 07·4 — under wbx · deploy kit

one fileBRINGSthe engine up

The parent lesson ended wbx deploy local as one line of magic. The deploy kit is the layer under it: one org file declares the engine, one converge verb set stands it up, and where it runs — your laptop or a cloud account — is a value in the file, not a different tool. Secrets declared by name, never baked in.

deploy kit12 min read
A small engineer at a console pressing a single key as a colossal dormant engine ignites and rises on its mount, light pouring from its seams against a bright orange sky — 1970s sci-fi style

the engine needs a MIDWIFE

Every other wbx engine verb assumes something is already running — wb rt status talks to a Nexus, wbx dock needs one to dock into. Deploy is the one verb that has no engine to call, because its job is to make the engine exist. It is the bootstrap verb: native-only — there is no wasm build of it, because a wasm verb needs a runtime to run inside, and at deploy time there isn't one yet.

The status quo answers this with a zoo. A YAML file for the orchestrator, a second CLI for the cloud, a secrets product wired to both, and a deploy script that scripts — fragile, order-dependent, complaining the moment a resource already exists. The reader who actually wants their workbooks to keep living after the laptop closes needs the layer under that one magic line: what file got written, what converged, where the secrets went, and what to type when it breaks. The deploy kit is that whole layer, and it fits on one screen.

the DEFINITION

de·ploy kit /dɪˈplɔɪ ˌkɪt/ noun

1. the bootstrap family inside wbx — one org file (deployment.org), one converge verb set (init · validate · apply · status · logs · down · doctor · secrets), and one provider seam — that stands an engine up from nothing, local or cloud, from a single declaration.

The kit is compiled into the wbx binary — it ships inside the same static executable as every other verb. The cli/deploy-kit/ directory on disk holds only its assets: example deployments, provider recipes, and a storage.env.example. You don't install the kit; you already have it the moment you have wbx.

the whole config, ANNOTATED

This is the entire declaration. Running wbx deploy init local scaffolds exactly this — five real lines and two comments, nothing hidden:

#+TITLE: Workbooks deployment
#+DEPLOY_TARGET: local
#+DEPLOY_APP: workbooks
#+DEPLOY_PORT: 4000
# Secret NAMES this deployment needs (values: `wbx deploy secrets set KEY=VAL`):
#+DEPLOY_SECRETS: OPENROUTER_API_KEY
# DEPLOY_IMAGE defaults to ghcr.io/workbooks-sh/runtime:latest (env WB_IMAGE overrides).

The parser is deliberately dumb: it collects every #+KEY: value and every :KEY: value property line into one uppercased map, so keyword style and property-drawer style both parse, and comments are just org. Each key has a fallback and a default:

deployment.org — every key the wbx verb reads
DEPLOY_TARGETwhere it runs — fallback PLACE, default local. This single value is the local/cloud switch.
DEPLOY_APPthe engine's name — fallback APP, default workbooks. Names the container or the fly app.
DEPLOY_REGIONcloud region — default sjc. Ignored locally; passed to the recipe in the cloud.
DEPLOY_PORTthe HTTP port — default 4000. Published on the container, set as internal_port in the cloud.
DEPLOY_IMAGEthe OCI image — default ghcr.io/workbooks-sh/runtime:latest. env WB_IMAGE overrides it.
DEPLOY_SECRETSspace-separated secret NAMES — declarations, never values.
DEPLOY_TOOLKITSid:dir id:dir … — toolkits the kit pushes to the engine after a cloud apply.
the image resolution ladder — WB_IMAGE env > #+DEPLOY_IMAGE > ghcr latest

That override ladder is the one worth memorizing: an exported WB_IMAGE beats whatever the file says, which is how you pin a SHA in CI or test a local build without editing the artifact. Everything else falls back to a sane default, so the five-line scaffold above is genuinely complete.

the converge LOOP

The verb set is the same manners every wbx family uses — init to scaffold, validate to check, apply to make it so, then status / logs / down to live with it. Three of them are worth a closer look because their behavior is the whole design:

  • init — on a TTY (a human), it shows a two-option picker: local — a container on this machine, cloud-identical versus cloud — fly.io, under your own account. Piped or run by an agent, it skips the prompt and takes the safe default, local. You can also name it outright: init local or init cloud.
  • validate — loads the file, checks that a non-local target has a resolvable recipe, and warns (doesn't fail) on declared-but-unset secrets. It prints ok — deployment.org (target=… app=… image=… port=…).
  • apply — the same checks, but it hard-fails on missing secrets. Validate tells you what's wrong; apply refuses to ship something half-configured.

Then apply / status / logs / down all dispatch on the one value in the file. Target local drives the built-in container seam; any other target hands off to that provider's recipe. Same verbs, same file — the fork is data, not code:

flowchart TD
  f[["deployment.org — the one declaration"]]
  f --> v["wbx deploy validate"]
  v -->|"ok / warns on unset secrets"| a["wbx deploy apply"]
  a -->|"target = local"| L["container seam
docker · podman · krunvm"] a -->|"target = <place>"| R["recipe
providers/<place>/bootstrap.sh"] L --> up["engine up"] R --> up up --> loop["status · logs · down
(dispatch on the same target)"] loop -.->|"re-run apply — converge, idempotent"| a style f fill:#d9dbd3,stroke:#121316,stroke-width:2.5px style up fill:#13d943,stroke:#121316 style L fill:#ffffff,stroke:#121316 style R fill:#ffffff,stroke:#121316

The dotted arrow is the point. apply is a converge, not a script: re-running it re-pulls the image, replaces the container, re-stages the secrets, and never complains that things already exist. Status and down read the same declaration to find what apply made. You don't manage steps; you state the end and run it as often as you like.

local, STEP by step

With #+DEPLOY_TARGET: local, apply finds a container engine in automation-friendliness order — docker → podman → krunvm, the first whose --version spawns. On docker or podman it amounts to exactly this:

docker pull ghcr.io/workbooks-sh/runtime:latest
docker rm -f workbooks                      # idempotent re-apply
docker run -d --name workbooks -p 4000:4000 \
  -e WB_WEB=1 -e WB_DESKTOP=1 -e PORT=4000 \
  -e WB_DESKTOP_DIR=/disco -e WB_DATA=/data \
  -v ~/…/sh.workbooks/disco:/disco -v ~/…/sh.workbooks/data:/data \
  --env-file ~/…/sh.workbooks/secrets.env \
  ghcr.io/workbooks-sh/runtime:latest
# → engine up — … discovery → …/disco/runtime.json · try `wb rt status`

The rm -f before run is what makes re-apply idempotent — there's never a name conflict, because the kit removes the old container first. Two volumes mount the durable state: /data holds the registry SQLite db (so your deployed workbooks survive a restart) and /disco is the discovery seam. The container writes a runtime.json into /disco — scheme, port, token — and the CLI's engine verbs read <app_dir>/disco/runtime.json to find the local engine automatically:

sequenceDiagram
  participant U as wbx deploy apply
  participant D as docker
  participant C as the container
  participant S as wb rt status
  U->>D: pull · rm -f · run (mounts /disco + /data)
  D->>C: start engine (WB_WEB=1, secrets via --env-file)
  C->>C: write /disco/runtime.json (scheme · port · token)
  U-->>U: print "engine up … try `wb rt status`"
  S->>C: read runtime.json → connect — no URL to type
  

That handshake is why local needs no configuration to talk to: discovery is a file on disk that only knows about local engines. For a remote engine you set WB_ENGINE_URL (and, if it's locked, WB_ENGINE_TOKEN) instead — more on that token in a moment. The krunvm path exists too, but carries an honest limit we name in the edges section.

names in the file, values in the VAULT

The artifact says what it needs; it never holds what it needs. #+DEPLOY_SECRETS: OPENROUTER_API_KEY is a declaration of a name. The value lives in <app_dir>/secrets.env, written chmod 0600, and managed by secrets set / list / unset / push. Here is the full round trip with the real output shapes:

$ wbx deploy secrets set OPENROUTER_API_KEY=sk-or-…
staged 1 secret(s) (~/Library/Application Support/sh.workbooks/secrets.env)
  — applied on next `wbx deploy apply` (or `secrets push`)

$ wbx deploy apply        # with a name still missing:
error: declared secrets unset: OPENROUTER_API_KEY —
  `wbx deploy secrets set KEY=VALUE` (or export them) first

Two delivery paths, one declaration: locally the kit passes --env-file secrets.env to the container; in the cloud it stages each value through the provider's set_secrets hook. Either way, values travel as environment at start time and never enter deployment.org or the image. The missing-secrets check also reads the deployer's own environment — so an exported OPENROUTER_API_KEY counts as present, and secrets list prints names only, never values.

flowchart LR
  d["#+DEPLOY_SECRETS:
OPENROUTER_API_KEY
(a NAME)"] v["secrets.env — 0600
(the value, or exported env)"] loc["local: --env-file
on the container"] cld["cloud: set_secrets
hook stages it"] env["container env
at start time"] art["deployment.org · the image"] d --> v v --> loc --> env v --> cld --> env v -. "NEVER" .-> art d -. "NEVER" .-> art style d fill:#d9dbd3,stroke:#121316 style env fill:#13d943,stroke:#121316 style art fill:#ffd9d9,stroke:#b00,stroke-width:2px,color:#b00

The red arrows are the whole promise: nothing on the value side ever flows into the artifact or the image. There's one secret the kit generates for you. On the first cloud apply it mints a 256-bit (32-byte) random token with the OS RNG, persists it as WB_PUBLIC_BEARER in secrets.env, and prints once:

control-plane locked — bearer token generated and persisted in secrets.env.
To talk to your engine locally, set:
  export WB_ENGINE_TOKEN=4f2a…64-hex-chars…
(stored in …/secrets.env — will NOT be regenerated on future applies)

It never rotates, on purpose — rotation would 401 every live client mid-flight. The runtime enforces the other half: once WB_PUBLIC_BEARER is set the engine is a locked deploy — a request with no token gets 401, with no fallback, and the dev x-tenant header trick only works when the engine is not locked. Lose the printed token and you recover it from secrets.env; it's the same value, sitting at 0600 where it always was.

same file, DIFFERENT value

Depth rung. Moving the engine to the cloud is editing one property: #+DEPLOY_TARGET: fly. No new tool, no second config language. On apply, the bundled fly recipe converges — creating only what's missing: it ensures the fly app exists, stages secrets with fly secrets set --stage (additive, released by the deploy), creates a 1 GB wbdata volume if absent, and synthesizes a minimal fly.toml in a temp dir that deploys your image by reference:

app = "my-workbooks-engine"
primary_region = "sjc"
[build]
  image = "ghcr.io/workbooks-sh/runtime:latest"
[mounts]
  source = "wbdata"          # 1GB volume, created if missing
  destination = "/data"
[http_service]
  internal_port = 4000
  force_https = true
  auto_stop_machines = "stop"   # scale-to-zero —
  min_machines_running = 0      # the engine sleeps when nobody's talking

That min_machines_running = 0 is scale-to-zero by default: the engine sleeps when idle and wakes on the next request, and your deployed workbooks survive the sleep because the registry db lives on the /data volume (litestream replicates that SQLite file). The public URL comes back as https://<app>.fly.dev. Then, if the file declared #+DEPLOY_TOOLKITS: id:dir …, the kit asks the recipe for that URL, points WB_ENGINE_URL at it, and pushes each toolkit — the kit ships the engine and its toolkits in one apply. Notice the local docker run and this fly.toml express the same engine contract: a container, a port, a volume, and secrets as env. That sameness is the provider-agnostic claim, made concrete.

five hooks and a SPINE

Depth rung. A provider here is not a plugin SDK — it's a shell file. local is the built-in container seam; anything else is a recipe at providers/<place>/bootstrap.sh that sources a neutral spine (_recipe.sh) and fills five hooks plus a lifecycle trio. Fly is just the one that ships bundled — explicitly a personal preference, not a privileged path:

hookits jobfly's one-liner
provider_ensure_appmake the app existcreate the fly app if missing
provider_set_secretsstage the declared valuesfly secrets set --stage (additive)
provider_attach_volumedurable /datacreate a 1GB wbdata volume if absent
provider_deploy_imagerun the engine containersynthesize fly.toml, deploy by ref
provider_public_urlwhere to reach ithttps://<app>.fly.dev
down · status · logsthe lifecycle trioapps destroy --yes · machine state · fly logs

The spine dispatches on WB_RECIPE_ACTIONup runs the full converge (ensure_app → set_secrets → attach_volume → deploy_image → echo the url), and secrets / down / status / logs / url each run their slice. The kit hands the recipe its environment env -i-style — a clean process carrying only PATH, HOME, the WB_* deploy vars, and each staged secret — so credentials reach the hook without ever touching the CLI's own process environment. The recipe is found in resolution order: $WB_PROVIDERS_DIR, then the repo (walking up from cwd for _recipe.sh), then copies embedded in the binary and materialized fresh under the app dir.

So adding AWS, Render, or your own VPS is dropping a bootstrap.sh — no recompile. The spine says it plainly: a provider implements only the engine contract — run my container, give it a volume, secrets, and a port — which ports cleanly to any host. There is no ephemeral-sandbox half to implement; it's one BEAM container with WASM inside. Authoring a full provider is its own deep dive — the recipes lesson — this page stays at the contract.

when it doesn't CONVERGE

wbx deploy doctor is the debugging entry point. It probes each engine and provider, lists which recipes resolved and from where, and reports your secret state — a single screen that tells you why apply can't proceed:

$ wbx deploy doctor
engines:
  ✓ docker        27.3.1
  ✗ podman        not found
  ✗ krunvm        not found
  ✓ fly           0.3.x
recipes:
  fly             ← embedded (materialized under sh.workbooks/providers)
secrets:
  ✗ missing: OPENROUTER_API_KEY

Three failures are canonical, and each names its own fix. No engine at all: no container engine (docker/podman/krunvm) — install one, or use `wbx deploy init cloud`. Image pull fails: the hint asks is it published? override with WB_IMAGE — because the default image is a published ghcr ref and a private build needs the override. Missing secrets: the exact bail from apply, declared secrets unset: … — `wbx deploy secrets set KEY=VALUE` (or export them) first. These tie back to the wbx exit-code map: a verb that can't reach the engine exits 3, and its hint names wbx deploy local — the loop closes back here, at the verb that brings the engine up.

edges, NAMED

The honest perimeter, because a bootstrap tool that hides its limits is the worst kind:

  • krunvm can't take the secrets env-file yet. The docker/podman path delivers --env-file; krunvm can't, so if a deployment declares secrets and only krunvm is present, apply bails — use docker/podman locally, or a cloud provider. It's a real gap, not a warning to ignore.
  • One cloud recipe ships today. Fly is bundled; everything else is a bootstrap.sh you (or someone) writes. The seam is open; the shelf is short.
  • Cloud needs flyctl and your own account. The recipe exits with an install URL if fly/flyctl isn't on PATH, and the app lands under your Fly account, not ours.
  • The file is ./deployment.org, in cwd. The wbx verb reads exactly that path — it takes no file argument. The richer example files under deploy-kit/deployments/ use a fuller axis set (:TENANCY_MODE:, :STORAGE:, :AUTH:) read by the engine-side backend, not by this verb. Those axes still ride to the engine — but as environment (see storage.env.example), not yet as first-class wbx keys. Treat them as where this grows, not as today's input format.
  • The bearer never rotates — and that's a feature. Rotation would 401 every live client; the kit chooses stability and stores the one token at 0600.

questions people actually ASK

Do I need Docker?

Any one of docker, podman, or krunvm covers local — the kit picks the first it finds. Or skip local entirely and go straight to cloud with wbx deploy init cloud, which needs flyctl and a Fly account instead.

Where exactly do my secrets live?

In <app_dir>/secrets.env — on macOS that's ~/Library/Application Support/sh.workbooks/, on Linux ~/.local/share/sh.workbooks/ — written chmod 0600. secrets list prints names only, never values, and the file never enters deployment.org or the image.

Can I re-run apply safely?

Yes — that's the whole model. Apply converges: it re-pulls, replaces the container (or creates only what's missing in the cloud), and re-stages secrets, without complaining that anything already exists. The one thing it will not touch is the bearer token — re-applying never rotates it.

How do I point wbx at the deployed engine?

Locally it's automatic — the container writes runtime.json into /disco and the CLI reads it. For a remote engine, set WB_ENGINE_URL, plus WB_ENGINE_TOKEN if it's a locked cloud deploy (that's the bearer you were shown once on first cloud apply).

AWS, Render, my own VPS?

Write providers/<place>/bootstrap.sh, fill the five hooks and the lifecycle trio on the neutral spine, and set #+DEPLOY_TARGET: <place>. No recompile — the kit resolves your recipe from WB_PROVIDERS_DIR or the repo. The full walkthrough is the recipes lesson.

Is `wb deploy` how Workbooks itself ships?

No — this is the users' tool, for standing up the runtime image for your own use. Workbooks' own platform releases (the runtime image, the compiler toolchain) ship through CI and a manual maintainer step, never through the deploy kit. The kit runs the engine; it doesn't publish it.

keep GOING

The deploy kit sits under wbx and stands up a Nexus — start with its parent, then follow the engine it brings to life.