the blessed-vendor TRAP
Every deploy tool you've used hard-codes its vendors. The cloud list is a feature matrix owned by someone else: Fly and Render and a handful of blessed targets, each with a panel in the binary. Want a cloud that isn't on the list? Wait for the maintainers to add it, or fork the CLI and recompile — and then carry that fork forever.
Even "bring your own cloud" usually means "bring your own YAML for our one blessed target." The provider is still code, still inside the compiled tool, still a thing only the authors can change. The shape of the problem is that where to deploy is a decision the binary made for you at compile time, and you live with it.
This lesson is about a tool that made the opposite choice: the binary
knows nothing about your cloud. It knows how to find a recipe and
run it. Adding a cloud is mkdir and an editor — no recompile,
no pull request, no waiting.
the DEFINITION
1. a bootstrap.sh at
providers/<place>/ that sources the neutral spine,
fills five hooks plus a lifecycle trio, sets
WB_RECIPE_PLACE, and calls wb_recipe_run — and
becomes a deploy target by existing, with no change to the
compiled tool.
A recipe is the engine contract written as shell: run my container, attach my volume, set my secrets, expose my port. Nothing more — the simplified model has no ephemeral-sandbox half to manage. One BEAM container with WASM inside, and a PaaS to hold it. That narrow contract is exactly why sixty lines is enough.
six actions, one CASE statement
Every recipe sources one neutral file — the spine, _recipe.sh —
and ends with wb_recipe_run. That function is the whole control
flow: a single case on WB_RECIPE_ACTION with
exactly six branches. An action the spine doesn't recognise exits 2; there
is no seventh door.
flowchart TD
a["WB_RECIPE_ACTION"]
a --> up["up — the full converge"]
a --> sec["secrets — stage, no deploy"]
a --> dn["down"]
a --> st["status"]
a --> lg["logs"]
a --> u["url"]
a --> x["anything else → exit 2"]
subgraph order["up runs the hooks in fixed order"]
direction TB
e1["wb_base_env"] --> e2["provider_ensure_app"] --> e3["provider_set_secrets"] --> e4["provider_attach_volume"] --> e5["provider_deploy_image"] --> e6["echo url: provider_public_url"]
end
up --> order
style a fill:#ffffff,stroke:#121316,stroke-width:2.5px
style order fill:#fbfaf6,stroke:#121316
style e6 fill:#13d943,stroke:#121316,stroke-width:2.5px
style x fill:#f3c5a3,stroke:#121316
style up fill:#d9dbd3,stroke:#121316
style sec fill:#d9dbd3,stroke:#121316
Read the diagram as one branch and one chain. The branch is the six
actions fanning out of WB_RECIPE_ACTION — up,
secrets, down, status,
logs, url — with everything else falling into the
exit-2 trapdoor. The chain is up expanded: it assembles the
engine environment, then calls four hooks in a fixed order — ensure the
app, set the secrets, attach the volume, deploy the image — and finishes by
echoing the public URL. That order is not negotiable, and that's the
point: the spine owns the choreography so no recipe has to.
The secrets action is the same machinery used carefully.
It runs only ensure_app and set_secrets — it
stages credentials onto the platform without a deploy. You can
hand a cloud your keys today and ship the image next week, and the two
steps never have to happen together.
five hooks and the TRIO
A recipe fills eight functions: five hooks the converge calls, and three
for the lifecycle. The spine guarantees each one a clean stage — by the
time a hook runs, WB_IMAGE is present and the
WB_ENGINE_ENV array is assembled. The house manner is
idempotence: converge, don't complain. A hook is run repeatedly, so
it checks before it creates and never errors on "already there."
| hook | its contract | fly's one-liner |
|---|---|---|
provider_ensure_app | the app exists after this | fly status || fly apps create |
provider_set_secrets | WB_ENGINE_ENV staged on the platform | fly secrets set --stage with the whole array |
provider_attach_volume | durable storage at WB_DATA | create wbdata 1 GB if missing |
provider_deploy_image | WB_IMAGE running, port exposed | fly deploy a synthesized fly.toml |
provider_public_url | print the reachable URL | echo https://$APP.fly.dev |
provider_down · _status · _logs | destroy · report · tail | fly apps destroy --yes · fly status · fly logs --no-tail |
The verdict of that table is how thin each hook gets to be. Because the
spine handed the hook a guaranteed image, an assembled environment, and a
fixed call order, the provider author writes one line per concern that
shells out to the platform's own CLI. The whole "deploy to Fly" behaviour
is six short functions wrapping flyctl — and the lifecycle
trio maps one-to-one onto three more.
what the engine is TOLD
Before any hook runs, the spine builds WB_ENGINE_ENV — the
exact set of variables the deployed engine boots with. Five pairs are
always present; a fixed list of axes is forwarded only if set; and every
name the deploy declared as a secret is appended. Nothing else reaches the
engine.
| variable | source | why |
|---|---|---|
WB_WEB=1 | always | boot the control plane, not a one-shot |
PORT | WB_PORT (default 4000) | where the http service listens |
WB_DATA | default /data | the mount point of the durable volume |
WB_EMBED | default local | how the engine sources its embedded artifacts |
WB_REGISTRY | $WB_DATA/registry.db | on the volume — the engine's default is in-memory; deployed workbooks must survive restarts and scale-to-zero |
| tenancy · storage · S3 · db axes | forwarded if set | opt-in surfaces — only what you configured rides along |
every name in WB_SECRET_KEYS | #+DEPLOY_SECRETS: via the kit | your declared credentials, by name |
The load-bearing row is WB_REGISTRY. The engine's default
registry is :memory: — fine for a laptop, fatal for a cloud
that scales to zero. So the spine pins it to a file on the durable volume,
and litestream replicates that SQLite database. The engine that wakes from
sleep is the engine that went to sleep, with every deployed workbook
intact. The same reasoning runs through the forwarded axes: a single
declared list (WB_TENANCY_MODE, WB_STORAGE,
WB_DATABASE_URL, the S3 quartet, and the rest) is forwarded
only if set, so a minimal deploy ships a minimal environment.
One discipline the recipe enforces by omission: it never rotates a
generated secret. WB_SIGNING_KEY and
WB_PUBLIC_BEARER are generated once and must be persisted by
the operator. Rotating them mid-flight 401s every live client and breaks
the tenant's DID. A recipe that quietly re-rolled them would be a recipe
that quietly logged everyone out — so it doesn't.
three rungs to find a RECIPE
If a provider is just a file, the only hard part is finding it. The CLI resolves the providers directory through three rungs, in order, first hit wins.
flowchart TD
start["wbx needs providers/"]
start --> r1{"WB_PROVIDERS_DIR set?"}
r1 -- yes --> use1["use it — rung 1, env wins"]
r1 -- no --> r2{"walk up cwd —
cli/deploy-kit/providers/_recipe.sh?"}
r2 -- found --> use2["use the repo — rung 2, dev"]
r2 -- no --> r3["rung 3 — standalone install"]
r3 --> mat["materialize bundled recipes
under the app dir
(rewritten every run)"]
mat --> use3["use app-dir/providers"]
style start fill:#ffffff,stroke:#121316,stroke-width:2.5px
style use1 fill:#13d943,stroke:#121316
style use2 fill:#13d943,stroke:#121316
style use3 fill:#13d943,stroke:#121316
style r3 fill:#d9dbd3,stroke:#121316
style mat fill:#fbfaf6,stroke:#121316
Walk the three rungs as one fallback. First, the environment:
WB_PROVIDERS_DIR wins outright — point it anywhere and that's
your providers root. Second, the repo: from your working directory the CLI
walks up looking for cli/deploy-kit/providers/_recipe.sh,
which is how it works during development inside the repo. Third, the
standalone install: with neither of those, the CLI materializes the
recipes it carries inside the binary — the spine and the fly
recipe are include_str!'d into the compiled tool — writing
them under the app dir and keeping them fresh by rewriting on every
invocation.
That third rung is why the tool is self-contained: a fresh install with
no repo and no env var still has a working fly recipe, because the binary
is carrying a copy of it. A provider exists, to the CLI, if and only if
providers/<place>/bootstrap.sh is a file. The check is
that literal — and wbx deploy doctor simply scans for
*/bootstrap.sh and lists what it finds, so you can confirm a
recipe is seen before you trust it.
the sealed SPAWN
depth rung · skippable — the security story, for the careful
Here is the part that makes shelling out to a cloud safe to do. The CLI
does not run bootstrap.sh in your shell. It runs it under
env -i — a sealed, empty environment — and hands in only an
explicit allowlist. Your stray exports, your other API keys, your
~/.env: none of it leaks into a cloud deploy, because the
recipe's process starts with nothing and receives only what the kit
decided to give it.
sequenceDiagram participant U as wbx (your shell) participant S as secrets.env (0600, app dir) participant B as bootstrap.sh (env -i) participant C as flyctl participant F as Fly API U->>S: read declared secret VALUES (staged first) U->>B: env -i + 9-var allowlist + secret values Note over B: starts EMPTY — only the allowlist exists B->>B: spine assembles WB_ENGINE_ENV B->>C: provider hooks shell out C->>F: create app · stage secrets · deploy image F-->>U: exit code honored all the way up
Read the sequence as a sealed handoff. The kit reads the declared secret
values — from the staged secrets.env store first,
your environment as fallback — then launches bootstrap.sh
under env -i with a nine-name allowlist:
PATH, HOME, WB_RECIPE_ACTION,
WB_PROVIDERS_DIR, WB_IMAGE,
WB_APP_NAME, WB_PORT, WB_REGION, and
WB_SECRET_KEYS — plus the secret values themselves and a small
set of app-build passthroughs. The recipe assembles its environment from
exactly that, shells out to flyctl, and its exit code is
honored all the way back up. The allowlist isn't a precaution layered on
top — it is the entire interface between your machine and the
recipe.
Two details earn their place. HOME is on the list on
purpose: it's how flyctl finds its own auth token — the recipe
never sees your Fly credentials, the platform CLI does. And the secret
store at rest is secrets.env under the app dir, written
0600; wbx deploy secrets list prints names, never
values. The kit's whole posture toward credentials is that they move by
name until the last possible moment, and the last moment is sealed.
the bundled recipe, read WHOLE
One recipe ships in the binary today, and its header states the stance
in plain words: Fly is OUR personal preference, not a privileged
target — any PaaS gets its own <place>/bootstrap.sh with
the same hooks. Read top to bottom, it's the worked reference for every
recipe you'll ever write.
It carries two image strategies. Prebuilt is the default for kit
users: deploy WB_IMAGE by reference, no repo needed — the
recipe synthesizes a minimal fly.toml on the fly and deploys
it. App-build is opt-in via WB_FLY_CONFIG and/or
WB_FLY_DOCKERFILE: Fly's remote builder builds from a repo,
which is the path our own production engine takes. The prebuilt path is the
one worth reading, because it's the one that proves a cloud needs no
project at all.
Here is exactly what it writes, to a temp directory, on every
up — and deletes after. There is no fly.toml in
your project, ever:
app = "my-workbooks-engine" primary_region = "sjc" [build] image = "ghcr.io/workbooks-sh/runtime:latest" [mounts] source = "wbdata" destination = "/data" [http_service] internal_port = 4000 force_https = true auto_stop_machines = "stop" auto_start_machines = true min_machines_running = 0
The last three lines are the economics. min_machines_running = 0
with auto_stop/auto_start means an idle engine
costs almost nothing — it sleeps when no one's knocking and wakes on the
next request. And it wakes whole, because the wbdata
volume — 1 GB, created by provider_attach_volume only if it's
absent — holds /data/registry.db. The engine that wakes up is
the engine that went to sleep, deployed workbooks intact. Scale-to-zero
plus a durable registry on a volume is the whole reason
WB_REGISTRY was pinned off in-memory two sections ago. The
rest of the recipe is one-liners: provider_public_url is
literally echo "https://$APP.fly.dev", and the lifecycle trio
is three thin fly calls.
write a provider this AFTERNOON
depth rung · skippable — the worked third-party recipe
So let's write one. Suppose your cloud is DigitalOcean's App Platform,
driven by doctl. A provider for it is a single file — source
the spine, declare the place, fill the eight functions with
doctl calls, end with wb_recipe_run:
# ~/wb-providers/droplet/bootstrap.sh
#!/usr/bin/env bash
set -euo pipefail
source "${WB_PROVIDERS_DIR}/_recipe.sh"
export WB_RECIPE_PLACE=droplet
command -v doctl >/dev/null || { echo "doctl not found" >&2; exit 1; }
provider_ensure_app() { doctl apps list --format Spec.Name | grep -qx "$WB_APP_NAME" || doctl apps create --spec <(spec); }
provider_set_secrets() { ...doctl apps update with WB_ENGINE_ENV pairs...; }
provider_attach_volume() { :; } # declared in the app spec
provider_deploy_image() { doctl apps create-deployment "$(app_id)"; }
provider_public_url() { doctl apps get "$(app_id)" --format DefaultIngress --no-header; }
provider_down() { doctl apps delete "$(app_id)" --force; }
provider_status() { doctl apps get "$(app_id)"; }
provider_logs() { doctl apps logs "$(app_id)"; }
wb_recipe_run
The before-and-after is the whole pitch. Before the file exists,
validate bails with the exact path it wanted; after you point
the resolver at your directory, doctor lists it beside fly and
apply deploys through it:
$ wbx deploy validate error: no provider recipe for `droplet` — expected providers/droplet/bootstrap.sh (WB_PROVIDERS_DIR, repo, or bundled) $ export WB_PROVIDERS_DIR=~/wb-providers # copy _recipe.sh in beside it $ wbx deploy doctor providers: droplet, fly (~/wb-providers) $ wbx deploy apply ==> [droplet] deploying my-workbooks-engine (ghcr.io/workbooks-sh/runtime:latest)
One caveat the failure message names: WB_PROVIDERS_DIR
replaces the search root — that's rung 1 of resolution — so your
directory needs _recipe.sh copied in beside your
droplet/ folder. The spine and your recipe live together; the
CLI points at the pair. That's the entire ceremony. No fork, no recompile,
no waiting on anyone — and your cloud is now a first-class target on a tool
you didn't change a line of.
one seam, three DRIVERS
depth rung · skippable — the dogfood proof
The same bootstrap.sh is executed by three different
callers, and that's the proof the seam is real rather than decorative.
flowchart TD cli["the Rust CLI
wbx deploy apply"] bk["the runtime's Elixir backend
System.cmd(bash, [boot])"] ci["our own CI
ci/deploy.sh"] boot["providers/fly/bootstrap.sh
one file"] cli --> boot bk --> boot ci --> boot boot --> fly["Fly — same hooks, same exit code"] style boot fill:#13d943,stroke:#121316,stroke-width:2.5px style cli fill:#d9dbd3,stroke:#121316 style bk fill:#d9dbd3,stroke:#121316 style ci fill:#d9dbd3,stroke:#121316
Three arrows converge on one file. The Rust CLI spawns it on
wbx deploy apply. The runtime's Elixir deploy backend spawns
it through System.cmd("bash", [boot]) and honors its exit
code, so the agent-experience exit contract reaches all the way down to the
shell. And our own production CI, ci/deploy.sh, exports
WB_RECIPE_ACTION=up with our app-build overrides and runs
exec bash .../fly/bootstrap.sh — the brandnana production
engine is deployed through the very same recipe a kit user runs. One deploy
path, dogfooded. The backend's own comment puts the seam plainly: adding a
provider is dropping a directory; the core never changes.
where the recipe ENDS
Honesty section. The model is small on purpose, and the edges are real.
- One recipe ships today. The
providers/directory contains the spine andfly/— nothing else. Every other cloud is a recipe someone writes. The seam is general; the library is one entry long. - A recipe shells out to the provider's own CLI. So
flyctl,doctl, or whatever your cloud speaks must be installed and authenticated. The recipe checks and tells you with the install URL — but it can't conjure the tool. - Secret values do reach the recipe's process environment, by
design — that's how the engine gets configured. The mitigation is that
the recipe is a shell script you can read in one screen, run under
env -iwith a known allowlist. There is no recipe registry and no signing yet; you are trusting a file you can audit, not a vendor's word. localis not a recipe. It's a separate, built-in container-engine seam, tried in orderdocker,podman,krunvm. Only targets that aren'tlocalroute to a recipe.- The contract presumes a PaaS shape — something that can run "container + volume + secrets + port." A raw VM with none of that means you build more inside the hooks: the seam doesn't shrink the work, it just gives it a fixed home.
questions people actually ASK
Do I have to fork the repo to add a provider?
No. Set WB_PROVIDERS_DIR to a directory of your own,
drop your <place>/bootstrap.sh in it next to a copy of
_recipe.sh, and the CLI uses it — that's rung 1 of
resolution, env wins. The binary is unchanged; your provider is
first-class.
Why bash, and not a real plugin API?
Because a recipe's job is to call a cloud's own CLI, and shell is the native language of calling CLIs. A plugin API would be a second contract to learn, version, and trust. Sixty lines you can read beats a binary interface you can't — and the spine already provides the structure a plugin API would have invented.
Where does fly get my API token?
From flyctl's own auth, via HOME — which is
on the spawn allowlist precisely so the platform CLI can find its
credentials. The recipe never handles your Fly token; it just runs
fly in an environment where fly already knows
who you are.
What if my PaaS has no volumes?
Then provider_attach_volume is where you solve it — bind
whatever durable storage your platform offers to WB_DATA,
because WB_REGISTRY lives at $WB_DATA/registry.db
and must survive restarts. No durable store at all means deployed
workbooks don't persist across scale-to-zero, and that's a real
limitation of that platform, not of the recipe.
Can a recipe rotate my bearer token?
No, and that's deliberate. WB_PUBLIC_BEARER is generated
once — a 256-bit hex value — persisted, and never re-generated. Rotating
it would 401 every live client and break the tenant's DID. The recipe
forwards it; it never re-rolls it.
Is Fly a privileged target?
No — and the recipe's own header says so. Fly is the maintainers' preference and the one recipe that rides along in the binary. Any other PaaS gets a directory with the same hooks and is exactly as much a first-class target.
keep GOING
Recipes is the seam behind the deploy verbs — these are the lessons it sits between.