learn / 07·6 — under wbx · recipes

a cloud isJUSTa shell file

Most deploy tools bake their cloud list into the binary — want a different vendor, fork and recompile. In the deploy kit a provider is a recipe: a ~60-line bootstrap.sh that fills five named hooks and becomes a deploy target by existing. The compiled tool's only job is to find that file and spawn it in a sealed shell. Fly isn't special.

recipes11 min read
A small figure in a monumental industrial kitchen-foundry where a single handwritten recipe card slots into a towering machine that pours out a glowing live server — bright primary colours, vast scale against the tiny cook, 1970s sci-fi style

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

rec·i·pe /ˈre·sə·pee/ noun

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_ACTIONup, 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."

hookits contractfly's one-liner
provider_ensure_appthe app exists after thisfly status || fly apps create
provider_set_secretsWB_ENGINE_ENV staged on the platformfly secrets set --stage with the whole array
provider_attach_volumedurable storage at WB_DATAcreate wbdata 1 GB if missing
provider_deploy_imageWB_IMAGE running, port exposedfly deploy a synthesized fly.toml
provider_public_urlprint the reachable URLecho https://$APP.fly.dev
provider_down · _status · _logsdestroy · report · tailfly 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.

variablesourcewhy
WB_WEB=1alwaysboot the control plane, not a one-shot
PORTWB_PORT (default 4000)where the http service listens
WB_DATAdefault /datathe mount point of the durable volume
WB_EMBEDdefault localhow the engine sources its embedded artifacts
WB_REGISTRY$WB_DATA/registry.dbon the volume — the engine's default is in-memory; deployed workbooks must survive restarts and scale-to-zero
tenancy · storage · S3 · db axesforwarded if setopt-in surfaces — only what you configured rides along
every name in WB_SECRET_KEYS#+DEPLOY_SECRETS: via the kityour 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 and fly/ — 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 -i with 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.
  • local is not a recipe. It's a separate, built-in container-engine seam, tried in order docker, podman, krunvm. Only targets that aren't local route 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.