boatramp
boatramp is a self-hosted, streaming-first alternative to Vercel and Netlify, shipped as one Rust binary that is both the server and the CLI. You run it yourself to publish static sites and functions — portable WASI components you run behind a route, invoke by name, or chain into workflows — with atomic deployments and instant rollback. The same commands and config run on a single node, a self-hosted cluster, or Cloudflare Containers.
Where to start
- New here? Publish something in ten minutes — Publish your first site.
- Running it in production? Start with Deploy a single node.
- Writing functions? Build and deploy one behind a route in Write your first handler, then invoke one by name.
- Automating or integrating? Read the authentication & authorization model.
- Want to chat? Join the community on Discord.
What boatramp does
| Static hosting | Content-addressed blobs, atomic deploys, instant rollback. |
| Domains & TLS | Virtualhosts, ownership verification, automatic certificates. |
| Auto-DNS | Ten managed-DNS providers for ACME and custom domains. |
| Functions | Portable WASI components — behind a route (handlers), invoked by name (sync/async), or metered & quota’d. |
| Workflows | Chain functions into a durable DAG with retries, fan-in/out, and compensation. |
| Compute | Containers and microVMs behind a route, with scale-to-zero. |
| Gateway | Load-balancing reverse proxy with health checks and retries. |
| Clustering | Raft-replicated control plane, multi-region reads. |
| Auth | COSE/CWT tokens, Cedar RBAC, external signers. |
| Caching & observability | Automatic caching, compression, metrics, and logs. |
Understand it
The core concepts explain the deployment model, and what boatramp is covers where it fits and what it is not. For per-capability release status, see Maturity, validation & support.
Publish your first site
In this tutorial you run a boatramp server, publish a one-page site, and load it — using only the files you create here. No build tool, no account, no config. By the end you will have published an immutable deployment and served it over HTTP.
You need the boatramp binary on your PATH. If you do not have it yet, see
Install boatramp.
1. Create a site folder
Make a folder with one HTML file:
mkdir my-site
cat > my-site/index.html <<'HTML'
<!doctype html>
<title>Hello from boatramp</title>
<h1>It works.</h1>
HTML
2. Start the server
In one terminal, run the server. With no arguments it serves plain HTTP on
127.0.0.1:8080 and stores data under ./data — enough for this tutorial:
boatramp serve
serving http://127.0.0.1:8080 — data ./data
Leave it running and open a second terminal for the next steps.
3. Publish the folder
Publish my-site as a deployment. sync uploads the files, records a manifest,
and activates the site — all at once:
boatramp sync ./my-site --server http://127.0.0.1:8080 --site my-site
scanned 1 file(s), 1 unique blob(s)
uploading 1 missing blob(s)… done
activated my-site -> 3b1c9f0a
4. Load it
Fetch the site at the server’s root. It is the only site you have published, so
boatramp serves it at / — the same place it will answer once you put it on a
real domain:
curl http://127.0.0.1:8080/
<!doctype html>
<title>Hello from boatramp</title>
<h1>It works.</h1>
You have published and served your first site. Once you publish a second site, you address each one by host — see How a request reaches your site.
5. Change and republish
Edit the page and publish again. Only the changed file uploads, and the site flips to the new deployment atomically:
echo '<h1>Second deploy.</h1>' > my-site/index.html
boatramp sync ./my-site --server http://127.0.0.1:8080 --site my-site
scanned 1 file(s), 1 unique blob(s)
uploading 1 missing blob(s)… done
activated my-site -> 7d42a1e8
curl the site again and you get the new page. The previous deployment still
exists — Publish, roll back, and alias a site shows how
to roll back to it in one command.
Where to go next
- Put it on a real hostname over HTTPS: Attach a custom domain and Get an automatic certificate.
- Run it as a real service: Deploy a single node in production.
- Add dynamic routes: Write your first handler.
Write your first handler
In this tutorial you build a WebAssembly handler, wire it to a route, and call it. You start from a handler boatramp ships as an example, so the build is guaranteed to work, then deploy it to a running server.
You need the boatramp binary (see Install boatramp, and
a server built with the handlers feature) and a Rust toolchain with cargo.
1. Get the example handler
boatramp’s repository ships example handlers under examples/handlers. The
simplest, http-200, exports wasi:http/incoming-handler and answers every
request with a fixed body. Clone the repository and change into it:
git clone https://github.com/BoatRamp/BoatRamp.git
cd BoatRamp
2. Build it to a component
A handler is a WebAssembly component built for the wasm32-wasip2 target. Add
the target once, then build the example in release mode:
rustup target add wasm32-wasip2
cargo build -p boatramp-example-http-200 --target wasm32-wasip2 --release
Finished `release` profile [optimized] target(s) in 21.4s
The component is at
target/wasm32-wasip2/release/boatramp_example_http_200.wasm. Copy it next to a
site folder you will publish:
mkdir -p site
cp target/wasm32-wasip2/release/boatramp_example_http_200.wasm site/hello.wasm
3. Wire it to a route
Create project.cfg in the project folder and declare the handler under
routing.handlers. This entry serves the component at /hello for GET
requests; it requests no host bindings:
(
publish: ( server: "http://127.0.0.1:8080", site: "my-site" ),
routing: (
handlers: [
( route: "/hello", component: "hello.wasm", methods: ["GET"], imports: [] ),
],
),
)
4. Enable handlers on the site
A deployment that ships handlers is refused at activation unless the site
permits them — the handlers.enabled
site policy, which is separate from the
deployment and which sync does not set. Enable it once. This handler requests
no imports, so there’s no allowlist to pass:
boatramp handlers enable --site my-site
(boatramp handlers show --site my-site prints the policy. You can also declare it in
an apply.cfg and run boatramp apply instead — see
Deploy a handler.)
5. Validate and publish
Check the config, then publish the site folder. The component blob is validated
at sync — parseability and the wasi:http/incoming-handler export:
boatramp validate
project.cfg: routing OK (1 handler: /hello [GET])
Start the server in another terminal (boatramp serve), then sync:
boatramp sync ./site
validated hello.wasm — exports wasi:http/incoming-handler
uploading 1 missing blob(s)… done
activated my-site -> 8c1f2a3d — handler /hello
6. Call the route
my-site is the only site on this server, so it answers at the root — call the
handler’s route directly:
curl http://127.0.0.1:8080/hello
hello from boatramp handler
Your handler is live. It ran in an in-process wasmtime sandbox, reached only what you granted (nothing, here), and streamed its response.
Where to go next
- Grant a handler data access: Use kv / sql / blobstore / messaging.
- Run work off the request path: Run consumers, crons, and streams.
- Deploy a component you built elsewhere: Deploy a handler.
Run a three-node cluster locally
In this tutorial you run a real three-node Raft cluster on one machine using the
dynamic-join model: one node founds, the others
join with a one-paste ticket, and you promote them to voters so the cluster
survives a leader loss. It uses loopback addresses and separate data directories,
so nothing conflicts. You need a boatramp binary built with the cluster (and
tls) features.
1. A root key + three configs
A cluster is defined by its root key. Generate one:
eval "$(boatramp auth init | grep '^BOATRAMP_AUTH_ROOT_')"
Each node gets a tiny boatramp.cfg — just its ports and store. There is no
node_id, peers, voters, or bootstrap: ids are derived and membership is
dynamic.
node1.cfg (the founder):
(
serve: ( addr: "127.0.0.1:8001", auth_root_public_key: "es256:…" ),
cluster: ( listen: "127.0.0.1:7001", store_dir: "/tmp/br1/raft" ),
)
node2.cfg / node3.cfg are identical except serve.addr (:8002/:8003),
cluster.listen (:7002/:7003), and store_dir (/tmp/br2//tmp/br3). Put
your BOATRAMP_AUTH_ROOT_PUBLIC_KEY in each auth_root_public_key.
2. Found node 1
Found the cluster, over raw-public-key TLS (so joiners can pin it), with a
single-use bootstrap secret to mint the first admin token. Keep
BOATRAMP_AUTH_ROOT_PRIVATE_KEY exported:
boatramp --config node1.cfg serve --cluster-init --tls rpk \
--bootstrap-secret s3cret
It logs its control-plane pin (--server-pubkey …) — export it so the CLI trusts
node 1, then mint an admin token:
export BOATRAMP_SERVER_PUBKEY=… # from node 1's startup log
export BOATRAMP_TOKEN=$(BOATRAMP_BOOTSTRAP_SECRET=s3cret \
boatramp token bootstrap --role admin --server https://127.0.0.1:8001 | head -1)
3. Join nodes 2 and 3
For each joiner, mint a one-paste ticket on node 1, then start the joiner with it (each ticket is single-use — mint one per node):
ROOT=$(boatramp auth pubkey --private-key "$BOATRAMP_AUTH_ROOT_PRIVATE_KEY")
T2=$(boatramp cluster add --server https://127.0.0.1:8001 --root-pubkey "$ROOT" | head -1)
boatramp --config node2.cfg serve --cluster-join "$T2" \
--cluster-advertise-addr https://127.0.0.1:7002
Repeat with a fresh ticket T3 for node3.cfg (advertise :7003). Confirm
membership — address-primary, the founder is the leader, the joiners are learners
catching up:
boatramp cluster status --server https://127.0.0.1:8001
ADDRESS ROLE NODE STATE
https://127.0.0.1:7001 leader 9f86d081 ready
https://127.0.0.1:7002 learner 3a7bd3e2 ready
https://127.0.0.1:7003 learner 1b4f0e98 ready
4. Promote to a voting quorum
Joiners start as read-only learners. Promote both so all three vote (needed to survive a leader loss). In Kubernetes the operator does this automatically:
boatramp cluster promote https://127.0.0.1:7002 --server https://127.0.0.1:8001
boatramp cluster promote https://127.0.0.1:7003 --server https://127.0.0.1:8001
cluster status now shows all three as voter/leader.
5. Publish to one node, read from another
Writes forward to the leader; every node serves reads from its applied state:
boatramp sync ./site --site my-site --server https://127.0.0.1:8001
curl http://127.0.0.1:8003/ # the page replicated from node 1
6. Watch it survive a leader loss
Stop node 1 (Ctrl-C). The remaining two voters hold a quorum and elect a new leader — ask a survivor:
boatramp cluster status --server https://127.0.0.1:8002
Reads and writes continue against the new leader. Restart node 1 and it resumes from its durable store and catches up from the log.
For the production version, see Deploy a self-hosted cluster and Run on Kubernetes.
Install boatramp
boatramp is a single binary — server and CLI in one. This page installs the
boatramp binary. Pick one method, then verify.
The prebuilt binary is batteries-included — it ships every non-conflicting feature (publish, serve, handlers, TLS + ACME, HTTP/3, clustering, the Kubernetes operator, the web console, and all blob/KV backends). For the platform matrix and the full feature list, see Cargo features & platform support; to build a smaller binary, see Build from source.
Every method ends with the same verify step:
boatramp --version
boatramp 0.4.23
Install script (Linux / macOS)
The script downloads the release archive for your OS and architecture, verifies
its checksum, and installs boatramp to ~/.local/bin:
curl --proto '=https' --tlsv1.2 -fsSL \
https://raw.githubusercontent.com/BoatRamp/BoatRamp/main/packaging/install/install.sh | sh
Set BOATRAMP_VERSION=vX.Y.Z to pin a version, or BOATRAMP_INSTALL_DIR=… to
change the target directory. On Windows, run the PowerShell script:
irm https://raw.githubusercontent.com/BoatRamp/BoatRamp/main/packaging/install/install.ps1 | iex
cargo install (crates.io)
With a Rust toolchain, install the released version from crates.io:
cargo install boatramp --locked
This compiles from source, pulling the batteries-included feature set (wasmtime,
TLS, cloud SDKs), so expect a sizeable build — the prebuilt binary above is faster.
Pin a version with cargo install boatramp@0.4.23 --locked, or build a smaller binary
with --no-default-features --features … (see Build from source).
Homebrew (macOS / Linux)
brew install boatramp/tap/boatramp
Container image
The image is multi-arch and runs as a non-root user:
docker run ghcr.io/boatramp/boatramp:latest --version
boatramp 0.4.23
To serve, publish the port and pass serve:
docker run -p 8080:8080 ghcr.io/boatramp/boatramp:latest serve --tls off
Nix / NixOS
Run or build straight from the flake:
nix run github:BoatRamp/BoatRamp -- --version # the latest commit
nix run github:BoatRamp/BoatRamp/v0.4.23 -- --version # pin a release
nix build github:BoatRamp/BoatRamp # -> ./result/bin/boatramp
On NixOS, the flake ships an overlay and a declarative services.boatramp module
with a hardened systemd unit:
imports = [ inputs.boatramp.nixosModules.default ];
nixpkgs.overlays = [ inputs.boatramp.overlays.default ];
services.boatramp.enable = true;
Prebuilt archive
Download the release archive for your platform from the
releases page, extract it, and
put boatramp on your PATH:
tar xzf boatramp-*.tar.gz
install -m 0755 boatramp ~/.local/bin/boatramp
For which archive targets your platform and which compute backends it includes, see Cargo features & platform support.
Next: publish a site
You have the binary. Publish something and serve it in Publish your first site.
Build from source
Compile the boatramp binary (server + CLI) yourself. The default build is
batteries-included — it enables every non-conflicting feature — so a plain
cargo build gives you the full capability set. For a smaller binary you can opt
down to just the features you want.
For prebuilt archives and packages instead, see Install boatramp.
Before you start
Install a recent stable Rust toolchain with rustup, then confirm it:
cargo --version
cargo 1.85.0
Clone the repository and change into it:
git clone https://github.com/BoatRamp/BoatRamp.git
cd BoatRamp
git checkout v0.4.23 # build a released version; omit to build the development tip (main)
Build the default binary
Build the boatramp package in release mode:
cargo build --release -p boatramp
Finished `release` profile [optimized] target(s) in 6m 05s
This is the batteries-included build: every non-conflicting feature is compiled in
(blobs on fs/S3/GCS/Azure, TLS + ACME, HTTP/3, the handler engine, clustering, the
Kubernetes operator, OIDC, external signers, the bundler, and the web console).
The binary lands at target/release/boatramp. (A from-source build embeds a
placeholder console unless you build the SPA first with just console.)
Build a minimal binary
To shrink the binary and its dependency tree, opt out of the defaults with
--no-default-features and name only the features you want. The smallest useful
build is filesystem blobs plus the SlateDB metadata store:
cargo build --release -p boatramp --no-default-features --features fs,slatedb
Finished `release` profile [optimized] target(s) in 1m 08s
Add more as you need them — e.g. --features fs,slatedb,tls,handlers for HTTPS
and the handler engine. Some features imply others: acme-dns and http3 each
pull in tls, and cluster pulls in handlers and slatedb. For every feature
and what it enables, see Cargo features & platform support.
Build with Nix
The flake pins the exact toolchain from rust-toolchain.toml, so the compiler
matches CI:
nix build
/nix/store/…-boatramp-0.4.23
The result is symlinked at result/bin/boatramp. Enter the dev shell with
nix develop for the pinned toolchain plus the just build, just test, and
just lint targets.
Verify the build
./target/release/boatramp --version
boatramp 0.4.23
See also
- Cargo features & platform support — the full feature list.
- Install boatramp — prebuilt archives, containers, and packages.
Publish, roll back, and alias a site
Every publish is an immutable deployment: boatramp sync uploads a folder’s
blobs, records a manifest, and activates the site to point at it. Activation
is a pointer flip, so switching between deployments is instant. This page covers
publishing, inspecting history, rolling back, and aliases.
Routing config (redirects, headers, SPA fallback) lives in project.cfg; see
Configure routing.
Publish a folder
sync negotiates a manifest with the server, streams only the blobs it is
missing, then activates the result:
boatramp sync ./dist --site my-site --server https://pad.example.com
scanned 128 file(s), 142 unique blob(s)
uploading 12 missing blob(s) (3.4 MiB)… done
activated my-site -> 4f3a2b2c
Re-running sync on an unchanged tree uploads nothing. Change one file and only
that blob uploads before the site flips. Every command on this page also accepts a
global --project <name> (env BOATRAMP_PROJECT, or [publish].project);
omitting it targets the reserved default project — byte-identical to pre-0.2.0.
See Organize sites into a project. Preview a publish without
writing anything:
boatramp sync ./dist --site my-site --dry-run
scanned 128 file(s), 12 changed — would upload 12 blob(s) (3.4 MiB), then activate
dry run: nothing uploaded
Inspect the current deployment
boatramp status --site my-site
my-site live 4f3a2b2c age 4m 128 files
Review history
boatramp deployments --site my-site
* 4f3a2b2c 2026-07-09 14:02 128 files
5c7742de 2026-07-09 11:18 127 files
1a09e3b4 2026-07-08 22:40 126 files
Label a deployment
So you can tell at a glance what a deployment is, sync records provenance
alongside it — shown in status, deployments, and the web console.
When run inside a git repo, sync captures the commit SHA, branch, and (via
git describe --tags) the nearest release tag automatically. Override any of
them, add a free-form message, or attach arbitrary key=value tags:
boatramp sync ./dist --site my-site \
-m "hotfix: cache headers" \
--tag env=prod --tag ticket=ABC-123
--tag is repeatable and takes key=value. All of it is optional metadata: it
never affects the (content-addressed) deployment id, and re-deploying an
unchanged tree preserves the prior provenance. status shows it in full:
my-site
deployment 4f3a2b2c
activated 4m ago
release v1.2.3
tags env=prod ticket=ABC-123
Roll back
Re-activate the previous deployment. Because activation is a pointer flip, this takes effect at once and uploads nothing:
boatramp rollback --site my-site
my-site rolled back to 5c7742de (was 4f3a2b2c)
Target a specific deployment by its id or a unique prefix:
boatramp rollback 1a09e3b4 --site my-site
my-site activated 1a09e3b4 (was 4f3a2b2c)
Point an alias at a deployment
An alias is a named pointer alongside the live site — a staging URL, a
per-branch preview. Point one at a deployment id (from deployments):
boatramp alias set staging 4f3a2b2c --site my-site
alias staging -> 4f3a2b2c
List and remove aliases:
boatramp alias ls --site my-site
boatramp alias rm staging --site my-site
To serve an alias on its own hostname, see Attach a custom domain. For every command and flag, see the CLI reference.
Organize sites into a project
A project is boatramp’s owning + tenant boundary. It groups many sites together
with their functions and compute, and it is the tenant a managed handler’s row-level
scope resolves to. Every resource belongs to exactly one project; a reserved
default project holds everything that predates projects, so if you never name a
project you keep the single-site experience unchanged.
Use projects when you run more than one site per operator (agencies, monorepos,
multi-tenant SaaS) and want each tenant’s sites, functions, and compute isolated —
including their names. Two projects can each own a site called blog.
Before you start
- A boatramp server and an admin token. See Bootstrap authentication & mint tokens.
- On an existing (pre-0.2.0) store, migrate it first — see Upgrade a store to project scoping.
1. Create a project
boatramp project create acme --display "Acme, Inc."
boatramp project ls
create needs a slug (unique, no /); --display, --description, and --region
are optional. project ls lists every project; project show acme prints the full
record; project rm acme deletes an empty project. It refuses while the project
still owns resources — and the refusal now enumerates exactly what remains, grouped by
resource family — so you know what to delete first. The default project can never be
removed.
To tear a project down wholesale, project rm acme --force cascades: it
deprovisions the project’s managed databases, removes its compute workloads and their
volumes, deletes its functions and sites (releasing the sites’ global domain claims),
clears its secrets and GraphQL safelist, then removes the project. Preview it first with
--dry-run (prints exactly what would be destroyed, changes nothing); a bare --force
shows that same plan and asks you to type the project name to confirm, so add --yes to
skip the prompt (required when stdin isn’t a terminal):
boatramp project rm acme --dry-run # what would be destroyed
boatramp project rm acme --force # cascade, with a typed-name confirmation
boatramp project rm acme --force --yes # cascade, unattended
2. Target a project
Every site-scoped command takes a --project flag; it falls back to
[publish].project in project.cfg, then the BOATRAMP_PROJECT environment
variable, then the default project. So these are equivalent:
boatramp --project acme sync ./dist --site blog
BOATRAMP_PROJECT=acme boatramp sync ./dist --site blog
With --project omitted you are working in default, byte-identical to how boatramp
behaved before projects existed. A site name only has to be unique within its
project, so acme/blog and beta/blog are two different sites that deploy, serve,
and run their background work independently.
3. Declare a whole project at once
boatramp apply reconciles an entire project from one manifest — see
Declare a project with apply. A minimal apply.cfg:
(
project: "acme",
sites: [
( name: "www", path: "www/dist" ),
( name: "blog", path: "blog/dist", routing: ( clean_urls: true ) ),
],
)
boatramp apply -f apply.cfg
What a project owns
- Sites — each with its own deployments, aliases, domains, and background work (consumers, crons). Same-named sites in different projects are fully isolated.
- Functions — top-level functions and their versions, triggers, invocations, and metering.
- Compute — container / micro-VM workloads.
- A tenant identity — a request routed to one of the project’s sites carries the
project as its host-asserted tenant, which is what a managed handler’s
Authorized::db()scopes rows to (nothing guest-supplied).
Content-addressed bodies (blobs, manifests, site and compute config) are shared across projects and deduplicated — a byte-identical asset uploaded by two projects is stored once, and it is only garbage-collected when no project references it.
Authorization
Cedar gains a Project resource with three project-scoped roles —
project_admin, project_publisher, project_viewer — that govern a project’s
sites, functions, compute, and workflows. A token scoped to one project cannot touch
another: a project_admin:acme token is refused (403) on project beta. Legacy
site-only grants (publisher:blog) read as publisher:default/blog, so existing
tokens keep working against the default project. See
RBAC roles, actions & resources.
See also
- Declare a project with
apply - Upgrade a store to project scoping
- Core concepts & the deployment model
Declare a project with apply
boatramp apply reads one RON manifest that declares a whole project — its
member sites, top-level functions, and compute workloads — and reconciles it to that
desired state in a single pass. It is the declarative counterpart to sync (one
site) and the imperative function / compute commands.
apply is pure upsert and never prunes: it touches only the resources the
manifest names, so declarative and imperative (CLI / API) management coexist — a site
you sync’d or a function you deployed by hand that is absent from the manifest is
left untouched. There is deliberately no --prune.
Before you start
- A boatramp server and a token that can publish. See Bootstrap authentication & mint tokens.
- Optional: an existing project —
applycreates a named project if it is missing.
1. Write apply.cfg
(
// Target project. Omit to use --project / BOATRAMP_PROJECT / default.
project: "acme",
sites: [
// A prebuilt folder.
( name: "www", path: "www/dist", routing: ( clean_urls: true ) ),
// A site with its own build step and a custom domain in its config.
(
name: "docs",
build: ( command: "npm run docs", output: "site" ),
config: ( domains: ( primary: "docs.acme.com" ) ),
),
],
functions: [
(
name: "resize", component: "resize.wasm", runtime: "wasm",
imports: ["sql", "invoke"], // requested host capabilities
env: { "IDP_JWKS": "https://idp/.well-known/jwks.json" },
invoke_targets: ["thumbnail", "img-*"], // deny-by-default invoke allowlist
),
],
compute: [
// v0.6.0: the compute spec is the typed `ComputeSpec` (`root` is the
// snake_case newtype variant `image(…)`), with sibling `replicas`/`placement`.
( name: "api", spec: ( root: image("ghcr.io/acme/api:1"), vcpus: 1, mem_mib: 512, port: 8080 ), replicas: 2 ),
],
)
Upgrading a pre-v0.6.0 manifest. In v0.6.0
compute[].specbecame the typedComputeSpec(it was a raw JSON blob before). A version-less manifest that still uses the old shape fails to parse with a message pointing here. To upgrade: addversion: 1at the top of the old manifest and runboatramp config migrate <file>(add--writeto rewrite it in place). The upgraded manifest omitsversion:(absent = current); declareversion: <the schema you wrote>only if you want migration support for a future upgrade.
Each sites[] entry is a slug plus:
path— the content directory (defaults to the site build’soutput, then.).build— an optional per-site build command run before publishing.routing— deploy-scoped routing (redirects / rewrites / headers / handlers / crons …), folded into the deployment so it is atomic with the content and rolls back with it. Same schema asproject.cfg’srouting.config— the mutableSiteConfig(domains, access, handlers enablement …), PUT after the deployment activates.
functions[] mirror boatramp function deploy: a component path plus an optional
runtime, webhook_secret_env, and — parity with a site handler — imports
(requested capabilities like sql / invoke), env (static, non-secret vars),
invoke_targets (the deny-by-default function-to-function allowlist), and limits.
A function that opens a sql/orm database in a multi-tenant project also carries a
tenancy block (its in-site tenant column + host source + per-axis access modes) and, when
its tenant comes from an app bearer token, a token_claims block (the JWKS/issuer that
verifies it) — see Isolate tenants within a project.
compute[] carry a raw spec PUT straight to the compute endpoint, the same body
boatramp compute set builds.
2. Preview the plan
boatramp apply -f apply.cfg --dry-run
--dry-run prints what would be built, deployed, activated, and PUT — and mutates
nothing (no build, no upload, no writes).
3. Apply
boatramp apply -f apply.cfg
apply resolves the target project (the manifest’s project:, else
--project / BOATRAMP_PROJECT / default), ensures a named project exists, then
reconciles each site, function, and compute workload in turn:
- Sites reuse the content-addressed
syncflow — hash the tree, upload only the blobs the server is missing, then atomically activate. Re-applying an unchanged site uploads nothing. - Functions and compute are create-or-replace PUTs to their project-scoped endpoints.
Because it is a create-or-replace upsert, running apply repeatedly is safe and
converges: the only writes are for resources whose content actually changed.
Mixing declarative and imperative
You can manage part of a project with apply.cfg and the rest by hand. Declare the
three sites you want version-controlled; keep the others on sync. apply never
enumerates or deletes resources it does not name, so a domain you attached with
boatramp domain add, an alias, or a token created out of band all survive an
apply. Management is cooperative (last-writer-wins per named resource), not
authoritative.
See also
Author configs in RON or JSON
boatramp’s config files — the apply manifest, boatramp.cfg, a site’s
project.cfg — are written in RON (Rusty Object Notation). RON is the native,
primary format: it maps directly onto boatramp’s typed Rust schema, supports
comments, and (via the IMPLICIT_SOME extension boatramp enables) lets you write
an optional field’s value directly instead of wrapping it in Some(…).
As of v0.6.5, boatramp also accepts JSON for the same files — the same typed schema, just a different surface syntax — so you can generate configs from a tool that emits JSON (Nickel, Jsonnet, CUE, a script) without a RON code path.
RON — the native format
RON is what the examples throughout these guides use, and what
boatramp config migrate emits. Its
ergonomics matter for hand-authored config:
(
project: "acme",
// Comments are allowed — RON is meant to be read and edited by hand.
sites: [
( name: "www", path: "www/dist", routing: ( clean_urls: true ) ),
],
// IMPLICIT_SOME: write the value directly, no `Some(…)` wrapper.
functions: [
( name: "resize", component: "resize.wasm", runtime: "wasm" ),
],
)
Prefer RON for anything you edit by hand.
JSON — for interop and generated configs
JSON deserializes into the exact same typed schema as RON. The parser is
selected by file extension — a .json file is read as JSON, anything else as RON
— or forced with --format:
$ boatramp apply -f apply.json # auto-detected: JSON
$ boatramp apply -f manifest.txt --format json # forced JSON regardless of extension
$ boatramp serve -c boatramp.cfg --format ron # forced RON
--format ron|json is available on the commands that read a config file
(apply, serve). Because JSON hits the same typed schema and the same
validators, everything behaves identically once parsed:
- Externally-tagged enums keep their variant names. A RON
kind: postgresis"kind": "postgres"in JSON; a newtype variant likeroot: image("…")is"root": { "image": "…" }. deny_unknown_fieldsstill applies. A typo’d or misplaced field is rejected in JSON exactly as in RON — JSON does not loosen the schema.- All semantic validation runs identically — routing compile-checks, the managed-database security contract, resource-name screening, and so on.
JSON example
The same manifest as the RON above, in JSON:
{
"project": "acme",
"sites": [
{ "name": "www", "path": "www/dist", "routing": { "clean_urls": true } }
],
"functions": [
{ "name": "resize", "component": "resize.wasm", "runtime": "wasm" }
]
}
JSON is current-schema only
There is no legacy JSON — JSON support arrived after config versioning, so
JSON is always parsed against the current typed schema. A version: field
declaring an older schema in a JSON file is an error: the loose-parse →
migrate pipeline is a RON-only concern.
If you have an old, versioned RON manifest, upgrade the RON source first with
boatramp config migrate, then (if you
want JSON) regenerate the JSON from the current-schema source. Don’t hand-write a
version: into JSON expecting a migration — regenerate current-schema JSON
instead.
Headline use case: generate configs from Nickel
The reason JSON exists as an input: you can author your config in a typed configuration language and export it to JSON for boatramp to consume. With Nickel:
$ nickel export --format json manifest.ncl > apply.json
$ boatramp apply -f apply.json
Your Nickel source can carry contracts, functions, and shared imports; the exported JSON is a plain, current-schema manifest that boatramp validates like any other. The same pattern works with Jsonnet, CUE, or a plain script — anything that emits current-schema JSON.
Because the parse is auto-detected by the .json extension, boatramp apply -f apply.json needs no extra flag; reach for --format json only when your
generated file has a non-.json name.
See also
--formatonapply/serve- Declare a project with
apply boatramp config migrate- The configuration model
Configure routing
Routing rules — redirects, rewrites, response headers, an SPA fallback, clean
URLs, the trailing-slash policy, and custom error documents — live in the
routing section of project.cfg. This section folds into the immutable
deployment manifest, so it activates and rolls back atomically with the content
it ships. Handlers, consumers, crons, and streams also live in routing; those
are covered in Deploy a handler.
Write the routing config
project.cfg is RON. Set the rules you need under routing:
(
publish: ( server: "https://pad.example.com", site: "my-site" ),
routing: (
// Serve /about for /about.html and drop the extension in links.
clean_urls: true,
// Send old paths to new ones. `:slug` captures a path segment.
redirects: [
(from: "/old/:slug", to: "/new/:slug", status: 301),
(from: "/blog", to: "/articles", status: 302),
],
// Long-cache fingerprinted assets by glob match.
headers: [
(matches: "**.js", set: { "Cache-Control": "public, max-age=31536000, immutable" }),
],
// Serve your own 404 page for unmatched paths.
error_documents: { 404: "/404.html" },
),
)
For a single-page app, add a rewrite so unmatched paths render the app shell instead of a 404:
rewrites: [ (from: "/**", to: "/index.html") ],
A rewrite serves a different file under the requested URL; a redirect sends the client a new URL with a 3xx status.
Route on the request (conditional rules)
A redirect or rewrite can carry a when condition — a small server-side
expression over the request — so the rule fires only when its from pattern and
its when both match. This does language- or file-aware routing without a
handler; it runs in the routing hot path (compiled at sync, evaluated in memory
per request).
Send visitors to their preferred language, in a single rule, with a ${…}
computed destination:
redirects: [
(
from: "/",
to: "/${prefers_language(['fr', 'en', 'de'])}/",
status: 302,
// Only redirect when a supported locale is actually accepted.
when: "prefers_language(['fr', 'en', 'de']) != ''",
),
],
Fall back to the English page when a localized file isn’t in this deployment:
redirects: [
(from: "/fr/*", to: "/en/:splat", status: 302, when: "!file_exists(path)"),
],
Conditions can read the method, host, path, header("name"), cookie("name"),
query("name"), accepts_language("fr"), prefers_language([...]), and
file_exists("/path"), combined with == != in && || ! and .startsWith/…. The
full grammar is in the routing reference.
Because a condition that reads Accept-Language, a cookie, or a header makes the
response vary per visitor, boatramp automatically adds the matching Vary
header so caches key on it correctly — no extra config needed.
Validate before you publish
boatramp validate parses project.cfg and checks the routing rules — glob
patterns, redirect targets, status codes — before anything ships:
boatramp validate
project.cfg: routing OK (2 redirects, 1 rewrite, 1 header rule, clean_urls on)
Migrating from Netlify or Cloudflare Pages? sync folds _redirects and
_headers files into this config, so you keep those rules without rewriting them
— see Migrate from Netlify / Cloudflare Pages.
Publish and verify
Publish the deployment, then confirm the redirect:
boatramp sync ./dist --site my-site
curl -sI https://pad.example.com/old/hello
HTTP/2 301
location: /new/hello
The redirect belongs to this deployment. Roll back — or activate a previous deployment — and the routing rules revert with the content in the same step; there is no separate routing state to reconcile.
Reference
- Full
routingschema and every field: project.cfg schema. - Match order, glob syntax, and precedence: Routing config schema.
Migrate from Netlify / Cloudflare Pages
Move a static site to boatramp without rewriting your redirect and header rules.
On sync, boatramp folds a Netlify-style _redirects file and a _headers file
from the root of your published folder into the deployment’s routing, so those
rules keep working as they are.
Before you start
- A built site directory (for example
./dist). - A boatramp server and a site name. See Publish, roll back, and alias a site.
1. Keep your build output as-is
Build your site with your existing toolchain. Do not change the output. Keep
_redirects and _headers at the root of the folder you publish:
dist/
├── index.html
├── _redirects
└── _headers
A _redirects line such as /old/* /new/:splat 301 and a _headers block carry
over unchanged.
2. Sync the folder
Point sync at the build output:
boatramp sync ./dist --site my-site
folded 4 rule(s) from _redirects, 2 from _headers
uploading 12 missing blob(s)… done
activated my-site -> 4f3a2b2c
The folded rules join the deployment’s immutable routing manifest, so they roll back atomically with the content.
3. Confirm a redirect
Request an old path and check the redirect and its target:
curl -sI https://my-site.example/old/page
HTTP/2 301
location: /new/page
Beyond _redirects and _headers
Those two files cover redirects and header rules. For rewrites, SPA fallback,
reverse-proxy targets, clean URLs, custom error documents, and handlers, write
the routing section of project.cfg. See Configure routing and
the project.cfg schema.
Upgrade a store to project scoping
boatramp 0.2.0 makes projects a first-class boundary and re-keys
the control-plane store so every mutable per-name record lives under
project/<proj>/…. A store written by an earlier release must be migrated to the new
layout before 0.2.0 will serve it. The migration is online, idempotent, and
resumable, and no content-addressed body ever moves — only the mutable pointers
re-key and the domain-routing index values are rewritten — so the blast radius is
small.
This is a one-time, per-store upgrade. A brand-new 0.2.0 store is already in the new layout and needs nothing.
What changes
- Sites, functions, compute, workflows, invocations, metering, aliases, and domain
verifications move under
project/default/…. - The domain index (
domain/<host>,wildcard/<suffix>,httpchallenge/…) keeps its global key; its value is rewritten from a bare site name to{project: "default", site}. A tolerant reader accepts both forms, so lookups never break mid-migration. - A
projectmeta/defaultrecord and anowner/*reverse index are created. - Content-addressed bodies (blobs, manifests, site/compute config) do not move.
Everything lands in the reserved default project, so URLs and behaviour are
unchanged after the upgrade (/api/sites/<name> and an omitted --project are
byte-identical to before).
Before you start
- Back up the store first. See Back up & restore. The migration is copy-before-delete and resumable, but a backup is your rollback.
- Plan a short maintenance window.
serverefuses to start on an unmigrated store unless you opt into auto-migration (below), so schedule the upgrade with the restart.
1. Dry-run
Scan the store and print exactly what would be re-keyed and rewritten, writing nothing:
boatramp migrate --dry-run
A non-zero exit flags an anomaly (for example a domain value it cannot interpret). Resolve those before proceeding.
2a. Migrate in one shot
For a single node or a small store, run the full migration:
boatramp migrate
It copies each key family to its new layout, verifies the copy, then deletes the old
keys — recording progress in a schema/version marker so an interrupted run resumes
to completion on the next invocation (re-running a finished migration is a no-op).
2b. Or stage it (copy → soak → finalize)
For a larger or busier store, split the copy from the delete so you can soak on the dual-read layout before committing:
boatramp migrate --stage # copy + verify, flip to the 2-dual layout
# ... serve; readers use the new keys and fall back to the old ...
boatramp migrate --finalize # delete the old keys, flip to layout 2
During the 2-dual stage the server reads the new keys with an old-key fallback, so
traffic is served throughout. --finalize runs only the delete pass.
3. Serve
Start the server as usual:
boatramp serve
On an unmigrated store serve refuses to start and tells you to migrate. If you
would rather migrate automatically at startup (for example in an appliance image),
pass --auto-migrate:
boatramp serve --auto-migrate
Clusters
Run the migration once. Execute boatramp migrate against the leader (it writes
through Raft, so the new keys replicate to every follower for free). A follower that
starts on a store still marked unmigrated blocks on the schema/version marker rather
than racing its own copy.
Verify
After migrating, confirm both a site and its routing still serve:
boatramp project ls # shows `default`
boatramp --project default sync ./dist --site <name> # a no-op re-deploy uploads nothing
curl -sSf https://<your-host>/ >/dev/null && echo ok
See also
Attach a custom domain
To serve a site on a hostname of your own — app.example.com — you attach that
host to the site, and it answers at that host’s root. boatramp routes a host only
after you prove you control it. For every way a request is matched to a site, see
How a request reaches your site.
domain add does as much as it can in one step: when the host already resolves to
this server, it verifies over HTTP and attaches immediately — no prior deploy, no
manual token juggling. When there’s still a manual step (a live domain pointing
elsewhere), it prints the challenge and you finish with domain verify.
Before you start
- A site to attach the host to.
- Control of the host: it either already points at this server, or you can serve a file on it (HTTP), or you have access to its DNS zone (DNS TXT).
- For the DNS-TXT method, a server built with the
domain-verify-dnsfeature.
The common case: the host already points here
If app.example.com already resolves to this boatramp server (its A/CNAME points
at the box, e.g. right after you cut a CNAME over to it), a single command
verifies and attaches it:
boatramp domain add app.example.com
started http verification for app.example.com
Serve this token, then run `boatramp domain verify app.example.com`:
GET http://app.example.com/.well-known/boatramp-domain-verification/7f3c9a2e…
body: 7f3c9a2e…
checking whether app.example.com already resolves here…
✓ verified app.example.com and attached it to my-site
boatramp serves its own challenge token from the edge (before host routing), so a host pointed at the server proves ownership over HTTP with no prior deploy — this is what removes the old “the host 404s its own challenge” chicken-and-egg. The host now routes and is eligible for a certificate.
Migrating a live domain (still pointing elsewhere)
When the host still serves live traffic from somewhere else, prove ownership over
DNS before you cut anything over. If a managed-DNS provider is configured, one
command publishes the _boatramp-verify TXT, waits for it to resolve, and
attaches — it never touches the host’s A/CNAME:
boatramp domain add app.example.com --provider cloudflare
See Automate DNS with a provider. Without a provider, add the TXT record yourself and verify in two steps:
boatramp domain add app.example.com --method dns
# add the printed _boatramp-verify.<host> TXT to your zone, then:
boatramp domain verify app.example.com
Because DNS proves zone control while the host still points away, you can verify and attach first, then cut the A/CNAME over when you’re ready.
Serving the token yourself (HTTP, host elsewhere)
If you’d rather prove control by serving a file — and the host isn’t pointed here
yet — start the challenge, place the token, then verify. --no-wait skips the
immediate self-check when you know there’s a manual step:
boatramp domain add app.example.com --no-wait
started http verification for app.example.com
Serve this token, then run `boatramp domain verify app.example.com`:
GET http://app.example.com/.well-known/boatramp-domain-verification/7f3c9a2e…
body: 7f3c9a2e…
then run `boatramp domain verify app.example.com`
Serve the token body at that path on the host, then:
boatramp domain verify app.example.com
verified app.example.com and attached it to my-site
If the check fails the host stays pending — confirm the token resolves (or the TXT
record has propagated) and run domain verify again. A pending host does not route
and cannot request a certificate.
Confirm the attachment
List the site’s domains to see what routes and what is still pending:
boatramp domain ls
app.example.com (primary)
beta.example.com
pending verification:
gamma.example.com (dns, unverified)
Verification is mandatory (and self-completing)
boatramp refuses to serve a public hostname until it is verified. A request
for a non-local host that isn’t an attached, verified virtualhost gets a friendly
“verification pending” holding page (HTTP 421) instead of any site content —
so a domain you don’t control can never be served just by pointing its DNS here.
Local names (localhost, *.localhost, *.local, and IP literals) are exempt,
and there is no implicit “sole site becomes the catch-all”: an operator sets a
fallback explicitly with boatramp config set default_site <site>.
You rarely have to finish by hand: a background reconcile loop re-checks every
pending challenge about once a minute and attaches any that now pass, so once
the TXT record or token file is published the host goes live on its own — no
domain verify needed.
Escape hatches (both operator-only):
-
Disable the gate fleet-wide in
boatramp.cfg(needs a restart — loosening the posture is deliberately not a runtime change):security: ( require_domain_verification: false ), -
Attach one host without a proof — an admin-only override that asserts ownership out of band. A site-scoped publisher cannot do this (they can’t claim a domain they don’t control); it needs a
system·admintoken:boatramp domain add store.example.com --unverified
Wildcard hosts (multi-tenant portal)
Attach a wildcard *.suffix to a site so every sub-label routes there — the pattern for a
multi-tenant portal where <tenant>.example.com all serve one app:
# A wildcard needs DNS-01 proof (no single host for an HTTP token) — see below.
boatramp domain add '*.example.com' --site my-portal --method dns
Exact always beats the wildcard. In the same suffix you can attach exact hosts to other
sites, and they win: console.example.com (attached to a console site) and a per-tenant custom
host serve their exact site, while tenant7.example.com (no exact claim) falls through to the
*.example.com portal — at any sub-label depth. So a tenant can never shadow an exact host, and
the hijack guard blocks one site from claiming a host (exact or wildcard) another already owns.
The portal handler sees the real Host (tenant7.example.com) on the wildcard route — on the
Host header, the wasi:http request authority, and X-Forwarded-Host — so it can resolve the
tenant by host. For HTTPS across all sub-labels, issue a wildcard certificate with
DNS-01. To wire a wildcard in a dev run with no real DNS, use the
admin --unverified override (boatramp domain add '*.example.com' --site my-portal --unverified);
it routes immediately.
Map each host to a tenant (domains.contexts)
When those per-tenant hosts share one database and you want each storefront to see only its
own rows, tag every host with a tenant context in site config and let the host — not your
handler — apply the row scope. domains.contexts is a map of host-or-wildcard-pattern → an opaque
tenant tag:
{
"domains": {
"primary": "console.example.com",
"wildcards": ["*.example.com"],
"contexts": {
"acme.example.com": "acme",
"globex.example.com": "globex",
"*.example.com": "shared-pool"
}
}
}
An exact host with no own entry inherits the primary’s tag (so apex↔www share a tenant); a
subdomain with no exact tag inherits its matching wildcard’s tag. When a function or handler
declares TenantSource::Domain, the host binds the matched tag as the in-site tenant value and
folds it into every query — the guest never supplies (or can spoof) it, and a host with no
resolvable tag fails closed rather than leaking across tenants. This is the declarative,
no-guest-code path to per-domain multi-tenancy; see
Isolate tenants within a project for the full model.
Remove a domain
Detach a host — attached or still pending — with domain rm. It stops routing
immediately:
boatramp domain rm app.example.com
detached app.example.com from my-site
Next: get a certificate
An attached host is eligible for a certificate but does not have one yet. Issue one so the domain serves over HTTPS — see Get an automatic certificate.
Get an automatic certificate
Issue a certificate for one domain from Let’s Encrypt and serve it over HTTPS.
boatramp requests the certificate on first start, caches it, and renews it before
expiry — no cron, no manual certbot.
For a wildcard certificate, a *.deploy.<host> preview certificate, or a domain
you cannot expose on the public internet, use DNS-01 instead — see
Wildcard certs with DNS-01.
Before you start
- The domain’s
A(andAAAA, if you serve IPv6) record points at the server’s public IP. - The host is attached to a site, so a request for it resolves to content — see Attach a custom domain.
- The ACME challenge reaches the server on port
443(and port80if you bind the redirect listener below).
Issue the certificate
Start serve in acme mode and name the domain:
boatramp serve --tls acme --acme-domain example.com --acme-contact ops@example.com
--acme-domain is repeatable — pass it once per domain to cover several on one
account. --acme-contact registers an email with the ACME account for expiry
warnings; it is optional but recommended.
On first start, boatramp registers the account, solves the challenge, and issues the certificate:
acme: registering account (contact ops@example.com) at Let's Encrypt production
acme: ordering certificate for example.com
acme: certificate issued for example.com — expires 2026-10-07, cached ./data/acme
serving https://0.0.0.0:8080
Verify the live site presents it:
curl -sI https://example.com/
HTTP/2 200
strict-transport-security: max-age=63072000
Redirect HTTP to HTTPS
Bind a second plain-HTTP listener so visitors on http:// are upgraded. In any
TLS mode, --http-redirect-addr answers plain HTTP with a 308 to HTTPS:
boatramp serve --tls acme --acme-domain example.com --http-redirect-addr 0.0.0.0:80
curl -sI http://example.com/
HTTP/1.1 308 Permanent Redirect
location: https://example.com/
Where the certificate is cached
boatramp writes the account key and issued certificate to --acme-cache (default
./data/acme). Restarts reuse the cached certificate instead of ordering a new
one, and renewal rewrites the same directory. Point it at durable storage and
back it up, or Let’s Encrypt rate limits apply the next time an empty cache
re-orders from scratch:
boatramp serve --tls acme --acme-domain example.com --acme-cache /var/lib/boatramp/acme
Reference
- All
serveTLS flags: CLI reference. - Wildcard and preview certs via DNS-01: Wildcard certs with DNS-01.
Wildcard certs with DNS-01
Issue a *.example.com certificate by proving control of the domain through a
DNS TXT record instead of an HTTP path.
Why wildcards need DNS-01
A wildcard name has no single host the CA can reach, so it cannot use the
challenge that --tls acme runs. DNS-01 is the only ACME challenge that
authorizes a wildcard: the CA gives you a token, you publish it as an
_acme-challenge TXT record, and the CA validates the record — not a path on
your server. To publish that record without hand-editing your zone, boatramp
drives a managed DNS provider through its API.
Issue the certificate
Set the provider’s credentials in the environment, then start serve with
--tls acme-dns. This example uses Cloudflare:
export CLOUDFLARE_ZONE_ID=… CLOUDFLARE_API_TOKEN=…
boatramp serve --tls acme-dns \
--acme-domain example.com \
--acme-dns-provider cloudflare
acme-dns: cloudflare provider ready
acme: authorizing example.com, *.example.com via dns-01
acme: published _acme-challenge.example.com TXT, waiting for propagation
acme: certificate issued (expires 2026-10-07)
serving https://0.0.0.0:8080
--acme-domain covers both the apex and its wildcard. Repeat the flag for more
domains.
The ten built-in providers are the same set the DNS automation uses —
cloudflare, route53, oci, digitalocean, hetzner, ns1, dnsimple,
gcp-dns, azure-dns, and akamai — each reading its credentials from
provider-specific environment variables. For the full provider-by-variable table
see DNS providers & credentials; for pointing
custom domains at your server see
Automate DNS with a provider.
Add preview subdomains
To serve by-id preview deployments over HTTPS, add --acme-wildcard-preview. It
issues *.deploy.<domain> alongside the primary wildcard:
boatramp serve --tls acme-dns \
--acme-domain example.com --acme-dns-provider cloudflare \
--acme-wildcard-preview
acme: authorizing *.example.com, *.deploy.example.com via dns-01
acme: certificate issued (expires 2026-10-07)
Publish the TXT record by hand
Without a provider account, use the default manual provider. boatramp prints
the record and waits for you to add it:
boatramp serve --tls acme-dns --acme-domain example.com --acme-dns-provider manual
acme: add this DNS record, then continue:
_acme-challenge.example.com TXT "3P1eF9…kQ"
acme: certificate issued (expires 2026-10-07)
Reference
- All
serveTLS flags: CLI reference. - Provider credentials: DNS providers & credentials.
Automate DNS with a provider
boatramp can drive your managed-DNS provider directly, so pointing a verified custom domain and proving ownership become single commands instead of manual zone edits. This page covers both tasks. For custom-domain concepts, see Attach a custom domain.
Before you start
- A supported managed-DNS provider with its credentials exported in your
environment. The
--providernames and their credential variables are in DNS providers & credentials. - A running server you can reach with
--server.
Credentials are read from the environment only, never from a config file.
Verify ownership automatically
Passing --provider to domain add closes the ownership-verification loop for
you. It publishes the _boatramp-verify.<host> TXT record through the provider,
polls until the record resolves, attaches the host, then retracts the challenge
record:
boatramp domain add app.example.com --provider cloudflare
published _boatramp-verify.app.example.com TXT for app.example.com; waiting for it to resolve...
verified app.example.com and attached it to my-site
--provider writes only the ownership-proof TXT — never the host’s A,
AAAA, or CNAME. Verification always happens before the host is pointed or
served, so boatramp cannot be induced to point or serve a hostname you have not
proven you control. Without a provider, domain add verifies over HTTP if the
host already resolves here, otherwise prints the record to publish by hand so you
can run domain verify afterward.
Point the domain at your server
Once the host is verified, point it at the server — a separate, explicit step.
The --target value decides the record type: an IPv4/IPv6 literal becomes an
A/AAAA, and anything else becomes a CNAME:
boatramp dns configure-domain www.example.com --provider cloudflare --target lb.example.net
pointed CNAME www.example.com -> lb.example.net
Use an address target at a true apex, where a CNAME is invalid:
boatramp dns configure-domain example.com --provider cloudflare --target 203.0.113.7
pointed A example.com -> 203.0.113.7
Add --proxied to route the record through Cloudflare’s edge (cache / WAF / edge
TLS). It is Cloudflare-only, chosen per domain, applies to address and CNAME
records, and forces the automatic TTL Cloudflare requires:
boatramp dns configure-domain docs.example.com --provider cloudflare --target app.fly.dev --proxied
pointed CNAME docs.example.com -> app.fly.dev (proxied)
Wildcard on Cloudflare: disable Universal SSL first
If you point a wildcard (*.example.com) at a Cloudflare DNS-only (grey-cloud)
zone and let something else terminate TLS with a wildcard certificate validated over
DNS-01 — for example a fly wildcard cert — Cloudflare’s Universal SSL will
silently block issuance.
Universal SSL (on by default for a newly-added zone) runs Cloudflare’s own
domain-control validation, whose managed TXT records at _acme-challenge.example.com
clobber the DNS-01 challenge delegation. The ACME CA reads Cloudflare’s tokens
instead of the delegated challenge, so the wildcard certificate never validates and sits
“Not verified” indefinitely. The failure is sneaky:
- It hits the wildcard only. An exact host (
console.example.com) validates over HTTP-01 and issues fine — so wildcard TLS hangs while exact-host TLS works, which looks like a fluke. - It is worst on new zones (Universal SSL still actively validating), and can recur at the external CA’s renewal time even on an established zone.
For a DNS-only zone the Cloudflare edge certificate is never served, so the fix is to
disable Universal SSL. boatramp dns configure-domain detects this: when you point a
wildcard at a Cloudflare DNS-only zone it checks the setting and, if enabled, prints a
warning. Pass --disable-cf-universal-ssl to turn it off in the same step (needs a token
with Zone.SSL and Certificates:Edit):
boatramp dns configure-domain '*.example.com' --provider cloudflare \
--target app.fly.dev --disable-cf-universal-ssl
Then re-trigger the wildcard cert so DNS-01 can validate against the now-unobstructed delegation, e.g.:
fly certs remove '*.example.com' && fly certs add '*.example.com'
Equivalent manual steps: dashboard SSL/TLS → Edge Certificates → Universal SSL →
Disable, or PATCH /zones/<id>/ssl/universal/settings {"enabled": false}.
For a proxied (orange-cloud) zone the edge certificate is served, so don’t disable Universal SSL — use a Cloudflare Origin CA certificate for the origin instead.
Reference
- Provider names and credential variables: DNS providers & credentials.
Bootstrap authentication & mint tokens
The control-plane API (publishing, config, tokens) authenticates; public serving never does. This guide takes a fresh server from no auth to a working admin token you can mint scoped tokens with. For the model behind it — COSE/CWT tokens, Cedar RBAC, offline verification — see Authentication & authorization.
1. Generate the root key
boatramp auth init
BOATRAMP_AUTH_ROOT_PRIVATE_KEY=es256:6f2c…
BOATRAMP_AUTH_ROOT_PUBLIC_KEY=es256:03a1…
This is an ES256 (P-256) key pair. The private key belongs to an issuing node — it verifies requests and mints tokens. The public key is the verification trust anchor; a verify-only node sets just that. To keep the private key out of process memory entirely, use an external signer (KMS / HSM / Vault) instead.
2. Start the server with the key
boatramp serve --auth-root-private-key "$BOATRAMP_AUTH_ROOT_PRIVATE_KEY"
control-plane auth enabled (issuer)
Any of --auth-root-private-key, the BOATRAMP_AUTH_ROOT_PRIVATE_KEY
environment variable, or serve.auth_root_private_key in boatramp.cfg enables
auth.
Warning: with no root key configured, auth is disabled — every control-plane request is accepted. Under the default
multi-tenantposture the server refuses to start this way on a non-loopback address. Never run a public, auth-off server.
3. Redeem a single-use bootstrap secret
token create mints through POST /api/tokens, which itself requires an admin
token — a chicken-and-egg on a fresh deploy. Break it with a single-use
bootstrap secret: set it on the server, redeem it once for an admin token,
then remove it. The server mints with its own root key, so nothing sensitive
leaves it, and the token comes back in the response body — never a log.
Set the secret on the server (alongside the root key):
boatramp serve \
--auth-root-private-key "$BOATRAMP_AUTH_ROOT_PRIVATE_KEY" \
--bootstrap-secret "$SECRET"
Redeem it from anywhere that can reach the server — no admin token needed:
BOATRAMP_BOOTSTRAP_SECRET="$SECRET" \
boatramp token bootstrap --role admin --server https://pad.example.com
eyJ… # the admin token — store it now, it is shown once
id: fb156b4f58909058 # metadata id, for `token ls` / `token rm`
The secret is single-use: redeeming it again returns 409. Store the admin
token, then remove the secret from the server. To bootstrap again later (a lost
admin token, key rotation), set a new secret and redeem it.
Note: a key holder can also mint entirely offline with
boatramp token mint, which signs locally through the configured signer (including a KMS/HSM) with no server round-trip. Reserve it for recovery when the server is unreachable;token bootstrapis the normal path, and its tokens are recorded and revocable.
4. Mint scoped tokens
Put the admin token in BOATRAMP_TOKEN, then mint narrower tokens through the
API:
export BOATRAMP_TOKEN=eyJ…
boatramp token create ci-deploy --role publisher:my-site
boatramp token create reader --role viewer:my-site --ttl-secs 86400
eyJ… # the new token — shown once
id: 024619fb948511f5
An admin token can mint any token, including another admin — so rotate a long-lived admin token before it expires instead of re-bootstrapping. Inspect and revoke tokens by their metadata id:
boatramp token ls # id, label, roles, expiry — never the token itself
boatramp token rm <id> # revoke the token and any delegations minted from it
--role is <role> (global) or <role>:<site> (site-scoped). See the full role
and rights model in RBAC roles, actions & resources.
Next steps
- Make a scoped CI deploy token — including offline attenuation.
- Sign in with OIDC.
- Hold the signing key in a KMS/HSM/Vault.
Reach the control plane on day zero (--tls rpk)
Before a host has a certificate, the control-plane API is normally reached over
plaintext loopback, an SSH tunnel, or a TLS-terminating proxy. On a bare-metal or
VPS node you often want an encrypted, authenticated control channel from the
first second — with no ACME, tunnel, or proxy. --tls rpk gives you that using a
raw public key (RFC 7250) the client pins.
This is for the operator/CLI channel, not public browser traffic (browsers can’t pin a raw public key). Your sites keep serving over ACME / custom certs as usual — this is orthogonal.
1. Serve with --tls rpk
boatramp serve --tls rpk --addr 0.0.0.0:8443 --data-dir /var/lib/boatramp
On startup it generates (once) a dedicated control-plane TLS identity at
<data-dir>/controlplane-tls.key (Ed25519, 0600) — not your root auth key,
so a KMS/HSM-held root signer keeps working — and prints its public key:
serving HTTPS (RPK bootstrap TLS) addr=0.0.0.0:8443 pubkey=302a300506032b6570032100db36…e28a
control-plane RPK TLS identity — pin the client with:
--server-pubkey 302a300506032b6570032100db36…e28a
The identity is public, not a secret — it’s the exact key the client verifies against. Note it (or read it later from the startup log).
2. Pin it from the client
Copy the printed key to BOATRAMP_SERVER_PUBKEY, and every boatramp command
pins the control plane to that identity over an encrypted channel:
export BOATRAMP_SERVER=https://cp.example.com:8443
export BOATRAMP_SERVER_PUBKEY=302a300506032b6570032100db36…e28a
export BOATRAMP_TOKEN=… # your control-plane token
boatramp token ls # …runs over pinned RPK TLS
- The channel is authenticated by the pin (a wrong or missing pin aborts the handshake — it never falls back to trusting an unknown key).
- You are authenticated by the bearer
BOATRAMP_TOKEN, exactly as over any other TLS mode.
If BOATRAMP_SERVER_PUBKEY is unset, the client uses ordinary WebPKI TLS — so the
same commands work unchanged once the host has a real certificate.
How it works
--tls rpk reuses boatramp’s cluster-mesh RFC 7250 stack (boatramp-rpktls): the
server presents its raw public key, the client verifies it is exactly the
pinned key — no CA, no hostname check, no notBefore/notAfter clock hazard.
Trust is established by that one out-of-band step: obtaining the key fingerprint
through a trusted channel (the startup log on the box you just provisioned). The
handshake is TLS 1.3 with the X25519MLKEM768 post-quantum-hybrid group.
When to use it
- First-boot / bare-metal / VPS: an encrypted control plane before ACME, with no tunnel or proxy — pin the printed key and go.
- Not for browsers or public site traffic — use
--tls acme/acme-dns/customthere. - On a platform that terminates TLS for you (fly.io, Cloudflare), you don’t need
this — run
--tls offbehind the platform’s edge.
Pin only the root key (one anchor for the fleet)
Copying each node’s TLS key doesn’t scale. Instead, pin only the key you already
trust — your control-plane root key — and let the server prove its TLS
identity. Under --tls rpk, an issuing node mints a root-signed attestation
of its TLS key and serves it (unauthenticated, a signed blob) at
/.well-known/boatramp-bootstrap-identity. Resolve it to a pin with auth pin:
boatramp auth pin --server https://cp.example.com:8443 \
--root-pubkey es256:03f6047fda… # your root PUBLIC key (auth pubkey / init)
verified https://cp.example.com:8443 against the root key. Export this to pin it:
BOATRAMP_SERVER_PUBKEY=302a300506032b6570032100…
It connects trust-on-first-use (recording the key the server presents), fetches
the attestation, verifies the root signature + validity, and confirms the
attestation names the presented key — placing no trust in the server until the
root signature checks out. Export the printed BOATRAMP_SERVER_PUBKEY and you’re
pinned. Rotating a node’s TLS identity re-mints a fresh attestation, so the same
root anchor keeps working with no client change.
Make a scoped CI deploy token
Give a CI job a token that can deploy exactly one site and nothing else. You mint
a site-scoped publisher token, store it as a CI secret, and — if you hand it
onward — narrow it further offline first.
This page assumes an admin token already exists in BOATRAMP_TOKEN. If not, mint
one first: see Bootstrap authentication & mint tokens.
1. Mint a site-scoped token
A role written as <role>:<site> grants that role on one site only.
publisher:my-site lets the holder deploy my-site and gives it no access to
any other site:
boatramp token create ci-deploy --role publisher:my-site
eyJ0…<the token, shown once>…9Qb
id: 3f9a2c1b7d04
The token prints to stdout once and is not recoverable; the id: prints to
stderr. Copy the token, and keep the id to revoke by later. For the role and
rights model, see RBAC roles, actions & resources.
2. Store it as a CI secret
Put the token in your CI provider’s secret store as BOATRAMP_TOKEN. The CLI
reads that variable directly, so the deploy step needs no extra flags:
boatramp sync ./dist --site my-site --server https://pad.example.com
uploading 12 missing blob(s)… done
activated my-site -> 4f3a2b2c
Because the token is scoped to my-site, a job that tries to touch another site
is rejected by the server.
3. Revoke when the job or key rotates
List issued tokens to find the id, then remove it. Revocation also revokes anything delegated from the token:
boatramp token ls
3f9a2c1b7d04 ci-deploy [publisher:my-site]
boatramp token rm 3f9a2c1b7d04
revoked 3f9a2c1b7d04
Narrow it further offline
To hand a further-restricted credential to a third party, attenuate the token offline — signing a restrict-only block with a holder key, no server and no root key involved. Attenuation can only subtract authority, never widen it.
Mint the token as delegatable first (--holder-pub <hex>, from boatramp auth init), then narrow it to read-only on the one site with an expiry:
boatramp token attenuate "$BOATRAMP_TOKEN" \
--holder-key "$HOLDER_KEY" \
--only-site my-site --read-only --not-after 1767225600
eyJ0…<narrowed credential>…Lm4
The narrowed credential verifies against the same root public key and is
presented in place of the original. Add --next-holder-pub <hex> to permit one
more attenuation down the chain. Revoking the original with token rm revokes
every credential delegated from it.
PoP-bind a control-plane token (DPoP)
A control-plane token is a bearer token: whoever holds the bytes can use it
until it expires or is revoked. If one leaks — a CI log, a .env, a laptop — it is
replayable as-is within that window.
A PoP-bound (proof-of-possession) token closes that gap. The token carries a
holder public key (cnf, RFC 8747); the matching private key never travels
with the token. On every request the client signs a small proof with that private
key binding this request (method, path, the server’s origin, the token, and — on
writes — the body). The server rejects the token unless a valid, fresh proof
accompanies it. A leaked token alone is then inert.
This is boatramp’s take on DPoP (RFC 9449) expressed over the existing COSE cnf.
It works over any TLS mode (public ACME, a proxy/CDN, or --tls rpk) — unlike
channel binding, it does not depend on the transport.
1. Set the server’s canonical origin
The proof binds an aud — the fleet’s public origin — which the server compares
against its configured value, never a Host/X-Forwarded-* header. Set it
once:
// boatramp.cfg
serve: (
pop_origin: "https://cp.example.com",
)
or --pop-origin https://cp.example.com / BOATRAMP_POP_ORIGIN. Without it, a
holder-bound token cannot be verified and is rejected — so configure it before
issuing PoP tokens.
2. Mint a PoP-bound token
token create --pop generates a fresh holder keypair, mints the token against its
public half, and prints both secrets as ready-to-export shell lines:
boatramp token create "ci deploy" --role publisher:blog --pop
BOATRAMP_TOKEN=g6Rh... # the token (a cnf/holder-bound COSE_Sign1)
BOATRAMP_TOKEN_HOLDER_KEY=es256:9f8c… # the holder PRIVATE key — the signing key
Store both now — neither can be recovered. The decisive win comes when the holder key lives somewhere the token does not (a secrets manager, an HSM/KMS): an attacker then needs two separately-held secrets, not one.
3. Use it
Export all three values; every boatramp command then signs a fresh proof per
request automatically — one seam, no per-command flags:
export BOATRAMP_SERVER=https://cp.example.com
export BOATRAMP_TOKEN=g6Rh...
export BOATRAMP_TOKEN_HOLDER_KEY=es256:9f8c…
export BOATRAMP_POP_ORIGIN=https://cp.example.com # matches the server's pop_origin
boatramp deployments --site blog # signed transparently
With no holder key set, the client is a plain bearer client (unchanged) — so a non-PoP token keeps working exactly as before.
4. (Optional) require PoP fleet-wide
A cnf token always requires a proof. To additionally forbid plain bearer
tokens across the whole node, turn on the require_pop posture knob:
// boatramp.cfg
security: ( overrides: ( require_pop: true ) )
boatramp security explain shows the resolved value. Now every token must be
holder-bound; a plain bearer is rejected with 401.
How it works
The per-request proof is a short COSE_Sign1 (br_kind = "pop") signed by the
holder key, binding:
htm+htp— the request method and path (the path survives a reverse proxy; the host/scheme are not trusted from the request).aud— the server’s configuredpop_origin.ath— a hash of the presented token (so a stolen proof can’t be paired with a different token).bh— a hash of the request body, on writes with a buffered body.iat+jti— issued-at (a tight ~60 s freshness window) and a unique id.
The server verifies the proof against the credential’s terminal cnf — so for
a delegated (attenuated) chain the binding follows the last
delegate, not the root — then runs a node-local replay check on the jti.
What it protects — and what it doesn’t
- Does: a leaked token is inert without the holder key; a captured proof is bound to one method+path+token+body and expires in ~60 s.
- Trade-off — cross-node replay: the
jtireplay cache is node-local (a shared cache would cost a consensus round-trip per request). A captured proof can be replayed on a different node within the freshness window — bounded by the tight window +athbinding + revocation, and documented rather than hidden. - Trade-off — streamed bodies: large/streamed uploads (blobs) are not body-bound (they carry their own content hash elsewhere); only method+path+token are bound for those.
- Not a fix for host compromise: if the token and the holder key sit in the
same place (co-located CI/
.env), PoP raises “steal one file” to “steal two files in the same place” — real defense-in-depth, not a substitute for holding the key separately.
Rollout & anti-downgrade
The server never accepts a cnf token without a valid proof — there is no
silent fall-back to bearer semantics. Roll out by upgrading nodes first, then
issuing cnf tokens: a token minted with --pop only verifies on a node that
enforces the proof, so a not-yet-upgraded node simply rejects it rather than
downgrading it. Flip require_pop on only once every node enforces PoP.
Sign in with OIDC
Enable OIDC on serve so users sign in with an identity provider you already run
(Okta, Keycloak, Auth0, Entra ID), then exchange the provider’s JWT for a
boatramp token. The control plane only ever authorizes boatramp tokens — the IdP
JWT buys you one, and nothing more. For minting tokens without an IdP, see
Bootstrap authentication; for why the exchange works this
way, see Authentication & authorization.
Before you start
- A configured root private key on the issuing node — the exchange mints tokens, so it needs the signer.
- A binary built with the
oidcfeature. - Your IdP’s issuer URL, the audience it stamps for boatramp, and the claim that carries role values.
1. Enable OIDC on serve
Pass the three OIDC flags alongside the root key:
boatramp serve --auth-root-private-key "$KEY" \
--oidc-issuer https://idp.example.com \
--oidc-audience boatramp-api \
--oidc-scope-claim scope
control-plane auth enabled (issuer)
oidc exchange enabled — issuer https://idp.example.com, audience boatramp-api
serving https://0.0.0.0:8080
Each flag has an environment variable — BOATRAMP_OIDC_ISSUER,
BOATRAMP_OIDC_AUDIENCE, BOATRAMP_OIDC_SCOPE_CLAIM — and a boatramp.cfg
entry. On startup the server fetches the issuer’s JWKS and refreshes it
periodically, so a key rollover at the IdP needs no restart.
--oidc-issuernames the trusted issuer; the server validates each JWT’siss,aud, andexpagainst that issuer’s keys.--oidc-audienceis the audience the JWT must carry. Set it: one issuer mints JWTs for many clients, and without an audience check a JWT minted for another client at the same issuer would exchange for a boatramp token. The server rejects any JWT whoseauddoes not match.--oidc-scope-claimnames the claim whose values map to boatramp roles — here thescopeclaim’s values become roles likepublisher:my-site.
2. Exchange a JWT for a boatramp token
Send the IdP JWT as the bearer to /api/auth/exchange on your boatramp server —
not the IdP:
curl -X POST https://pad.example.com/api/auth/exchange \
-H "Authorization: Bearer $OIDC_JWT"
{"token":"eyJhbGciOiJFUzI1NiIs…","roles":["publisher:my-site"],"expires_in":3600}
The server validates the JWT against the issuer’s JWKS, maps the scope-claim
values to roles, mints a short-TTL boatramp token, and returns it. Use that token
as Authorization: Bearer (or BOATRAMP_TOKEN) for every control-plane call. A
rejected JWT — wrong aud, expired, or an unknown signing key — returns 401,
and no token is minted.
Hold the signing key in a KMS/HSM/Vault
Keep the token root signing key outside the boatramp process so it never sits in
process memory. The server resolves the key’s public half at startup — the trust
anchor — and calls the backend to sign each minted token; the private key stays
in the KMS, HSM, or Vault. Configure this under serve.signer in boatramp.cfg.
Verification needs only the public key and stays offline: every node authorizes
requests without contacting the signer. Only minting — token creation, OIDC
exchange, offline token mint — calls the backend, so only the issuing node
needs it. For the wider picture, see
Authentication & authorization.
Before you start
- Provision the root key in your backend as an ES256 (P-256) signing key. The
cloud KMS backends sign ES256 only;
Vault,Pkcs11, andLocalalso takealg: Ed25519. - Make sure the backend’s Cargo feature is compiled in. All signer backends are
in the default (batteries-included) build; only a
--no-default-featuresbuild needs to re-add one (--features signer-aws/signer-gcp/signer-azure/signer-vault/signer-pkcs11). - Put the backend’s credential in an environment variable. The config names the variable; the secret itself never goes in the file.
Sign through a cloud KMS (AWS)
Point serve.signer at the key. AWS credentials come from the standard provider
chain (instance role, AWS_* env vars), not the config:
serve: (
signer: AwsKms(
key_id: "arn:aws:kms:eu-west-1:123456789012:key/abcd-…",
region: "eu-west-1",
),
),
Sign through HashiCorp Vault
Target a Vault Transit key. The Vault token comes from the environment variable
named in token_env:
serve: (
signer: Vault(
address: "https://vault:8200",
key: "boatramp-root",
token_env: "VAULT_TOKEN",
alg: Es256,
),
),
Start the server. serve.signer supersedes auth_root_private_key:
VAULT_TOKEN="$(vault print token)" boatramp serve --config boatramp.cfg
signer: external Vault(boatramp-root) alg=es256
control-plane auth enabled — verification offline, minting via signer
serving https://0.0.0.0:8080
The six backends
Each maps to a serve.signer variant and one Cargo feature:
| Backend | Cargo feature | serve.signer variant |
|---|---|---|
| Local key | (built-in) | Local(private_key) |
| AWS KMS | signer-aws | AwsKms(key_id, region) |
| GCP Cloud KMS | signer-gcp | GcpKms(key_version, access_token_env) |
| Azure Key Vault | signer-azure | AzureKv(vault_url, key, key_version, access_token_env) |
| HashiCorp Vault | signer-vault | Vault(address, key, token_env, alg) |
| PKCS#11 HSM | signer-pkcs11 | Pkcs11(module, token_label, key_label, pin_env, alg) |
For the full field tables — which fields are optional and the accepted alg
values — see the boatramp.cfg schema.
Restrict visitor access
Control who can reach a site’s public content: password-protect a staging site, allow or deny by IP, and cap request rate. These controls are per-site and apply before any content is read, so a blocked request never stalls a response in flight.
Requests pass the controls in order — WAF → IP rules → rate limit → basic auth — and the first to reject wins. This page covers public-facing access only. To publish a private upstream or tune the SSRF guard, see Load-balance & proxy upstreams; to manage control-plane operators and tokens, see Bootstrap authentication.
All commands take --site (or read it from project.cfg). Show the current
policy:
boatramp access show --site my-site
site my-site
basic-auth 0 users (disabled)
ip no rules
rate-limit disabled
Password-protect a site
Add a basic-auth user. The password is read from --password or, if omitted,
from stdin; it is stored argon2id-hashed, never in plaintext. Visitors without
valid credentials get a 401 challenge:
boatramp access basic-auth add preview --realm "Staging" --site staging
basic-auth: added user 'preview' — site 'staging' now requires authentication
Remove a user, or disable basic auth entirely:
boatramp access basic-auth rm preview --site staging
boatramp access basic-auth disable --site staging
Allow or deny by IP
IP rules take a CIDR or a bare address. Adding an allow rule denies every unlisted client; deny wins over allow:
boatramp access ip allow 203.0.113.0/24 --site my-site
ip: allow 203.0.113.0/24 — unlisted clients denied
boatramp access ip deny 198.51.100.7 --site my-site
Clear all IP rules with boatramp access ip clear. Behind a reverse proxy, the
client address is read from X-Forwarded-For only when the direct peer is a
trusted proxy — register yours:
boatramp access trusted-proxy add 10.0.0.0/8 --site my-site
Apply a rate limit
Set a per-client sustained rate and an optional burst. Over-limit requests get
429:
boatramp access rate-limit set 20 --burst 40 --site my-site
rate-limit: 20 rps, burst 40 (per client IP)
In a multi-process deployment, serve --cluster-rate-limit so the count is
shared through the control-plane KV instead of counted per node. Disable the
limit with boatramp access rate-limit disable.
The WAF
The web-application firewall is the outermost filter in the ordering above. Its
signals are part of the site’s access policy; a request the WAF rejects is
answered 403 before any other check runs.
Choose & inspect a security posture
The security posture is the operator’s trust model, resolved at startup from
boatramp.cfg. It decides defaults for hazards a site writer must not control:
whether a public bind may run without auth, upload and component size caps,
whether a site may reach private-network upstreams, whether compute may share
the host kernel, and whether a database-opening handler may skip an in-site
tenancy declaration or reach across tenants. The posture
is operator-only — it is never part of site config,
so a site-write principal cannot relax it. For why the model exists, see
The security posture model.
Pick a profile
Set security.profile in boatramp.cfg:
security: ( profile: "single-tenant" )
| Profile | For |
|---|---|
multi-tenant (default) | untrusted site writers on an untrusted network — strict. |
single-tenant | one operator who owns every site — relaxed. |
dev | local development — loopback-loose. |
A profile is sugar over the individual knobs; the knobs are the source of truth.
Override individual knobs
Layer overrides on the profile to tune one setting without leaving the strict
baseline:
security: (
profile: "multi-tenant",
overrides: (
max_upload_bytes: 104857600, // 100 MiB (0 = unlimited)
allow_site_private_upstreams: true, // let sites' gateways reach private IPs
),
)
The full knob list is in the boatramp.cfg schema.
Per-project overrides (v0.4.7)
The four tenancy/capability knobs — require_tenancy_declaration, allow_cross_tenant_db,
allow_guest_mint_capability, max_guest_capability_ttl_secs — can be overridden per project,
so one serve process can host a strict-isolation project beside a looser one on a shared,
multi-project machine:
security: (
profile: "multi-tenant", // the fleet default
projects: {
"acme-preview": ( // looser, just for this project
allow_cross_tenant_db: true,
allow_guest_mint_capability: true,
),
},
)
Only these four in-project knobs are per-project; every other knob (egress, upload caps, domain
verification, …) stays fleet-wide. Cross-project isolation is structural (project = database) —
never a knob, so a per-project override can only tune that project’s own in-project strictness and
its guests’ capability-mint ceiling, never its reach into another project. The four knobs
compose: you can require an explicit tenancy declaration and permit specific components to be
all and allow guest capability minting (clamped by max_guest_capability_ttl_secs), all at once.
These four knobs (and the other posture bools) are also BOATRAMP_SECURITY_* env-settable — e.g.
BOATRAMP_SECURITY_ALLOW_CROSS_TENANT_DB=true — so a 12-factor deploy needn’t ship a boatramp.cfg
just to tune them. (Per-project overrides are config-file only.)
Inspect the resolved posture
security explain prints the effective posture — every knob’s value and where it
came from (profile or override):
boatramp security explain --config boatramp.cfg
posture: multi-tenant (+2 overrides)
allow_unauthenticated_public_bind false (profile)
max_upload_bytes 104857600 (override)
allow_site_private_upstreams true (override)
allow_shared_kernel_compute false (profile)
…
Run this before exposing a server: it is the authoritative answer to “what will this server allow?”
Define a named profile
For a reusable posture, declare it under profiles and select it:
security: (
profile: "ci",
profiles: {
"ci": ( allow_unauthenticated_public_bind: true ),
},
)
Each named profile is a set of overrides layered over the strict multi-tenant baseline.
Encrypt secrets at rest
The control plane stores cluster-managed certificate private keys. By default they sit cleartext in the (replicated) KV. Envelope encryption wraps each key with a key-encryption key (KEK) so the stored bytes are ciphertext; only a node holding the KEK can unwrap them.
Configure it with the secrets: section of boatramp.cfg. Two backends:
Local KEK
A machine-local AES-256-GCM key, auto-generated 0600 on first use:
secrets: (
envelope: "local",
kek_file: "/var/lib/boatramp/secrets/kek",
)
boatramp serve --config boatramp.cfg
secrets: local envelope (KEK /var/lib/boatramp/secrets/kek)
Warning: in a cluster the wrapped certificates replicate to every node, so every node needs the same KEK file to unwrap them. Distribute the one KEK to all nodes, or use the Vault backend instead — a per-node KEK cannot decrypt another node’s wrapped keys.
Vault Transit
Delegate wrapping to HashiCorp Vault’s Transit engine. No KEK file is distributed; each node authenticates to Vault. The Vault token comes from the environment, never the config file:
secrets: (
envelope: "vault",
vault: (
addr: "https://vault:8200",
key: "boatramp-certs",
token_env: "VAULT_TOKEN",
),
)
VAULT_TOKEN="$(vault print token)" boatramp serve --config boatramp.cfg
secrets: vault envelope (transit key boatramp-certs @ https://vault:8200)
Vault avoids the shared-KEK-file problem in a cluster: every node unwraps through Vault with its own token, so there is no key file to copy between hosts.
What is protected
The envelope wraps, in the control plane: cluster-managed certificate private
keys, managed-database credentials (the per-tenant passwords boatramp
generates for a managed Postgres/MySQL), and the internal secret store that
backs boatramp:<name> references (see Give handlers & functions
secrets). Configuring a secrets: envelope is therefore a
prerequisite for the boatramp secrets commands.
Back the KEK up alongside your other secrets — losing it makes everything the envelope wrapped unrecoverable (boatramp re-issues certificates, but stored secrets and managed-DB credentials cannot be re-derived). See Back up & restore.
Give handlers & functions secrets
A site handler or a function often needs a secret — a third-party API key, a
database URL, a signing token — that must not live in the committed config.
boatramp handles this with a secrets map whose values are references, not
values: the reference is resolved server-side at instantiation and injected
into the guest’s environment; the secret itself never lands in the manifest, a
log, or an API response.
// in a site's [handlers] config, or a function's config
secrets: {
"STRIPE_KEY": "boatramp:stripe-key", // ← from the sealed internal store
"DATABASE_URL": "env:DATABASE_URL", // ← from the serve process env
}
The guest sees STRIPE_KEY / DATABASE_URL in its environment; the config only
ever carries the left-hand names and the right-hand reference.
The reference schemes
A secrets value is parsed by its scheme (the part before the first :); a
value with no colon is a bare host-env var name.
| Reference | Resolves to | Allowed when |
|---|---|---|
env:NAME or bare NAME | the serve process’s own environment variable NAME | single-tenant / dev only |
boatramp:NAME | the project-scoped sealed internal store (below) | always (multi-tenant-safe) |
vault:…, aws:…, any other scheme: | reserved for a future resolver | refused (“not yet supported”) |
A missing referent (an unset env var, or a boatramp: secret that isn’t set) is
logged and skipped — never injected as an empty value. Any scheme boatramp
doesn’t resolve is refused rather than misread as a host var, so a value with a
colon is never silently treated as an environment variable.
Why env: is gated to single-tenant
An env: / bare reference reads the operator’s process environment — the
namespace that also holds other tenants’ credentials, the managed-database
superuser password, and cloud keys. When the config author is the operator
(single-tenant, dev) that’s fine. Under the multi-tenant posture the config
author is an untrusted tenant, so a bare/env: reference is refused,
fail-closed (the host environment is never even read) — otherwise a tenant
could name any host variable and exfiltrate it. Multi-tenant deployments use
boatramp: instead, which resolves only within the tenant’s own project.
The internal store: boatramp secrets
boatramp:NAME reads from boatramp’s own project-scoped, sealed secret
store. Values are sealed at rest with the [secrets] key
envelope (the same one that wraps certificate keys and
managed-database credentials) and stored per project, so a boatramp: reference
resolves only within its own project — never another tenant’s secrets, never
the host environment.
Set a secret (the plaintext is sealed server-side — the CLI never holds the KEK, and the value is never written to the manifest or echoed back):
# preferred: read the value from stdin or a file (no shell-history trail)
printf '%s' "$STRIPE_KEY" | boatramp secrets set stripe-key --stdin
boatramp secrets set stripe-key --file ./stripe.key
# convenient, but leaves the value in shell history / the process table:
boatramp secrets set stripe-key --value sk_live_…
List (names + metadata only — there is no way to read a value back):
boatramp secrets ls
NAME REVISION UPDATED
stripe-key 2 1724980000
Rotate (an alias for set — overwrites, bumps the revision) and remove:
printf '%s' "$NEW_KEY" | boatramp secrets rotate stripe-key --stdin
boatramp secrets rm stripe-key
All of these are project-scoped via the global --project flag (default
default); managing secrets requires a project-admin (or global-admin) token —
a publisher can reference boatramp:stripe-key but only an admin provisions
it.
Requires a
[secrets]envelope. The store seals every value, so the secret commands (and anyboatramp:reference) need a[secrets]envelope configured — see Encrypt secrets at rest. Without one the API replies501with a clear message. In a cluster, every node needs the same KEK (or Vault Transit) to unwrap, exactly as for certificate keys.
See also
- Encrypt secrets at rest — the envelope that seals the store.
- Use kv / sql / blobstore / messaging — the other guest bindings.
- Deploy & invoke a function — a function’s
secretsmap works identically.
Send email from a function or handler
A function or handler often needs to send mail — a signup-verification link, a
password reset, a receipt. boatramp offers this as a first-class, per-project
capability: the guest imports email and calls send; boatramp holds the SMTP
credentials and delivers the message. The guest never sees the credentials and
can’t reconfigure the relay — it only uses the service. boatramp is the SMTP
gateway, not a templating engine: the guest renders its own HTML/plaintext
(with whatever it likes — e.g. the mrml MJML crate) and hands boatramp a finished
message.
Configure a profile
A profile is one named SMTP relay: host / port / security / AUTH plus the
default sender. Configure it with boatramp email — the password is sealed
server-side (the CLI never holds the KEK) and stored per project.
# STARTTLS submission relay (587), password from stdin (no shell-history trail):
printf '%s' "$SMTP_PASSWORD" \
| boatramp email set default \
--host smtp.example.com --security starttls \
--username apikey --password-stdin \
--from 'no-reply@example.com'
# a second, named profile (a guest picks it by name):
boatramp email set marketing --host smtp.example.com --security tls \
--username apikey --password-stdin --from 'hello@example.com' < key.txt
--security is starttls (587), tls (implicit TLS, 465), or plaintext (a
trusted local relay only); --port overrides the conventional port. Omit
--username/--password for an unauthenticated relay. Add --durable to make
this profile’s sends default to the durable spool (below).
Updates merge — change one field at a time. email set on an existing profile
overwrites only the fields you pass and keeps the rest, including the sealed
password (so a tweak never re-transmits or accidentally wipes your credentials):
boatramp email set default --from 'noreply@example.com' # just the From; auth untouched
boatramp email set default --host smtp2.example.com # just the host
printf '%s' "$NEW" | boatramp email set default --password-stdin # rotate only the password
boatramp email set default --no-auth # drop username + password (open relay)
boatramp email set default --durable false # flip the durable default off
List and inspect (redacted — the password is never returned), and remove:
boatramp email ls
NAME HOST SECURITY FROM DURABLE
default smtp.example.com:587 starttls no-reply@example.com false
boatramp email show default # host/port/security/from + whether a password is set
boatramp email rm marketing
All of these are project-scoped via the global --project flag (default
default); managing profiles takes the same right as secrets
(Secrets·Write) — a project admin, not a publisher.
Use it from a guest
Grant the capability by importing email, then call send with a finished
message. The guest picks a profile by name (none ⇒ default) and supplies its
own text and/or html:
// the host interface your component imports
use boatramp:handlers/email-sender.{send};
use boatramp:handlers/email-types.{email-message};
#![allow(unused)]
fn main() {
// inside the guest, having rendered your own bodies
let msg = EmailMessage {
profile: None, // → the project's "default" profile
to: vec!["dest@example.org".into()],
cc: vec![], bcc: vec![],
from: None, // → the profile's configured sender
reply_to: None,
subject: "Confirm your email".into(),
text: Some("Visit https://…/verify?t=abc to confirm.".into()),
html: Some("<p>Visit <a href=\"https://…/verify?t=abc\">confirm</a>.</p>".into()),
durable: None, // → the profile default
};
send(&msg)?; // Ok = accepted for delivery (spooled), not yet delivered
}
At least one of text/html is required. A from you set must match the
profile’s configured sender (a guest can’t spoof an arbitrary From); omit it to
use the default. send returns as soon as the message is accepted for delivery
— delivery happens asynchronously off the request path, so a slow relay never
blocks your handler.
Declare the requirement in your function manifest’s requires so a deploy is
refused on a host that doesn’t offer email, rather than failing at first send.
Best-effort vs durable delivery
- Best-effort (default): the message is queued in memory and delivered by a background task with a few retries. Zero persistence overhead; a node crash mid-flight loses the queued message.
- Durable (opt-in, per message via
durable: Some(true), or per profile via--durable): the message is persisted onto boatramp’s messaging fabric and delivered by a worker with lease/retry/dead-letter — it survives a restart. A message still failing after the max attempts lands in the dead-letter queue, where you can inspect and redrive it withboatramp dlq. The durable path needs a messaging backend (always present on a normal node).
Limits
Because a guest that can send at all could otherwise weaponize the operator’s shared relay, every send is bounded (fail-closed, before it is spooled):
- ≤ 100 recipients per message (
to+cc+bcc) and ≤ 2 MiB total body (subject+text+html) — over either andsendreturnsinvalid-message. - A per-project send rate (sustained 5 msg/s, burst 50), enforced across both
the best-effort and durable paths — over it and
sendreturnsspool-failed(“rate exceeded”). The budget is per project (a busy tenant can’t throttle another) and per node.
These bound mass-mail abuse and durable-queue amplification without getting in the way of normal transactional bursts (a signup wave, a batch of receipts).
Security posture
Guest email is governed by the allow_guest_email
posture knob: off under multi-tenant (an untrusted
tenant can’t use the shared node’s SMTP egress until the operator opts in), on
under single-tenant/dev. When it is off, a handler that imports email is
refused at deploy, and a granted send returns access-denied. The SMTP relay
host is additionally held to the SSRF rule — a relay resolving to a
private/loopback address is refused unless allow_guest_private_egress is on — so
a tenant profile can’t aim the client at an internal service.
Requires a
[secrets]envelope. A profile’s password is sealed at rest, so the[secrets]envelope configured — see Encrypt secrets at rest. Without one the API replies501with a clear message. In a cluster every node needs the same KEK to unwrap, exactly as for secrets and certificate keys.
See also
- Give handlers & functions secrets — the sealed store the email
password reuses; the same
Secretsright manages both. - Encrypt secrets at rest — the envelope that seals the profile.
- Choose & inspect a security posture — the
allow_guest_emailknob. - Run consumers, crons, and streams — the fabric +
dlqthe durable spool rides.
Let an app configure its own project
Some apps need to reconfigure their own project from inside the sandbox — a SaaS
adding a customer’s custom domain, a setup wizard writing its own SMTP profile, an
installer rotating a secret. The usual way is to embed a project_admin bearer token
in the app and call the admin API — but that’s a standing, over-broad credential you
have to rotate forever.
The admin capability replaces the token. A guest imports the specific config
surfaces it needs; boatramp confers the power at deploy time (grant + posture),
bounded to the guest’s own project and the granted surfaces, with nothing to
rotate. It is strictly less authority than a project-admin token: no cross-project
reach, no critical/node ops, verb-scoped, rate-limited, and audited.
This is the “manage config” companion to email (send) and
secrets (use): those let a guest use managed config; admin lets a
guest manage a curated slice of it.
The four surfaces
Each surface is a separate grant (admin:<surface>); a bare admin grants nothing.
| Surface | Grant | Verbs |
|---|---|---|
| Domains | admin:domains | domain-add, domain-verify, domain-remove, domain-list |
admin:email | email-set, email-delete, email-list | |
| Site config | admin:site | site-config-get, site-config-put |
| Secrets | admin:secrets | secret-set, secret-delete, secret-list |
What is never reachable: project create/delete, tokens, authz policy, root
anchors, cluster membership, blobs, cache purge, prune/scrub, daemon config, compute exec, sql exec/query, and all node-global compute (volumes/DNS/IPAM/…). There is no
verb and no binding for any of these — ever.
Grant it
Add the surface(s) to the handler/function imports in the routing manifest, and to
the site’s allow_imports ceiling. The effective grant is the intersection:
imports ∩ allow_imports ∩ posture.
// routing manifest — the handler declares what it needs
handlers: [(
route: "/admin/*",
component: "setup.wasm",
imports: ["admin:domains", "admin:email"], // this handler self-configures domains + email
)],
# the site's ceiling must also permit those surfaces
boatramp handlers set --site app --allow-imports admin:domains,admin:email
The admin surfaces are gated at runtime, not by the requires ABI gate: if the
allow_guest_admin_<surface> posture is off (the default under multi-tenant), the admin
binding never attaches and the verb returns access-denied at call time. requires is for
capabilities whose availability varies by host build (e.g. session,
tenancy) — an admin:<surface> is not an advertised build feature, so listing it in
requires would make every deploy fail as unmet. Gate the surface through
imports ∩ allow_imports ∩ posture instead.
Use it from a guest
use boatramp:handlers/admin.{domain-add, domain-verify, email-set};
use boatramp:handlers/admin-types.{email-profile};
Add a customer’s custom domain (the guest never holds a token):
#![allow(unused)]
fn main() {
// 1) start verification — returns the challenge token to publish
let challenge = domain_add("app", "shop.customer.com", "http")?;
// publish `challenge.token` at the challenge location for the customer's domain
// (a /.well-known/… file for http, a TXT record for dns)
// 2) prove ownership — boatramp runs the SAME real-network probe as the normal flow
if domain_verify("app", "shop.customer.com")? {
// verified + attached: routing now serves shop.customer.com for site "app"
}
}
Set an SMTP profile (a create-or-merge; unset fields keep the stored value, exactly
like boatramp email set):
#![allow(unused)]
fn main() {
email_set("default", &EmailProfile {
host: Some("smtp.example.com".into()),
port: Some(587),
security: Some("starttls".into()),
username: Some("apikey".into()),
password: Some(secret_from_your_config), // sealed host-side; never returned
from: Some("no-reply@customer.com".into()),
durable: None,
clear_auth: false,
})?;
}
The site argument on the domain/site verbs must be one of the guest’s own
project’s sites — it is validated within the host-stamped project, so a crafted
string can’t reach another tenant (or another project).
Invariants (why it’s safe to hand config power to untrusted code)
- Project-scoped, host-stamped. The controller is bound to the guest’s own project at instantiation (never from guest input). Cross-tenant configuration is structurally impossible, not merely checked — the project is the un-escapable KV key prefix.
- Domain ownership stays proven.
domain-verifyruns the same real-network probe as the normal flow; a wildcard still needs DNS-01. There is no guest path to attach an unverified domain (that route isSystem·Adminonly). Asite-config-putthat references a not-yet-verified domain is refused for the same reason. - Deny-by-default, per-surface. Only the granted, site-allowed surfaces attach;
an ungranted verb is
access-denied. - Write-only credentials. A guest may set an SMTP password or a secret, but no verb ever returns a password/secret value — reads are redacted name lists only.
- Rate-limited + audited. Every operation is charged to a per-project quota
(sustained 2/s, burst 20 — config changes are rare), and every mutation writes a
structured audit record (project, surface, verb, target, outcome) to the host’s
boatramp::auditlog target — never the rate-capped guest log stream, so an audit event is never dropped.
Security posture
The admin capability is governed per surface by the
posture knobs allow_guest_admin_domains,
allow_guest_admin_email, allow_guest_admin_site, and allow_guest_admin_secrets:
all off under multi-tenant (untrusted tenants can’t self-configure until the
operator opts in), all on under single-tenant/dev. When a surface’s knob is
off, no binding for it attaches and its verbs return access-denied. Override an
individual knob (e.g. enable only admin:domains fleet-wide) the same way as any
posture field — see Choose & inspect a security posture.
Email/secrets surfaces need a
[secrets]envelope.admin:emailandadmin:secretsseal values at rest, so they require a[secrets]envelope — see Encrypt secrets at rest. Without one those verbs fail closed with a clear message;admin:domains/admin:sitework regardless.
See also
- Send email from a function or handler — the
emailuse capability theadmin:emailsurface configures. - Give handlers & functions secrets — the sealed store
admin:secretswrites into. - Attach a custom domain — the operator-side flow
admin:domainsmirrors. - Choose & inspect a security posture — the
allow_guest_admin_*knobs.
Enable the embedded web console
boatramp ships a small web management console — a WebAssembly single-page app
that drives the control-plane /api (sites, deployments, tokens, config,
observability). It is baked into the binary and served, when you turn it on,
from the same origin as the API. Nothing to deploy separately, no CORS to
configure.
Turn it on
Every shipped build already bakes the console in — the console feature is on
by default, and the release binaries and the Nix/OCI images stage the real SPA.
So on a prebuilt boatramp there’s nothing to compile; you only enable serving
it in boatramp.cfg:
serve: (
addr: "0.0.0.0:8080",
console: (
enabled: true,
),
),
Restart serve and open https://<your-host>/_console. That’s it.
Turn it on at runtime (no restart)
The console mount is also a dynamic daemon-config knob, so you can enable it on a running instance — or a whole fleet — over the control-plane API, with no restart and no redeploy:
boatramp config set console.enabled true # serve it now, fleet-wide
boatramp config set console.host console.example.com # optional: pin the host
boatramp config set console.path /admin # optional: move the path
boatramp config set console.enabled false # turn it back off
The [serve.console] block above is the baseline; a console.* dynamic
override wins over it (an unset override defers to the file). This is the tier to
reach for when you can’t edit the file + restart — e.g. a managed fly.io / OCI
instance. (Needs an admin token; enabling the mount grants no privilege — the
console’s static assets hold no secrets and the API stays token-gated.)
Building from source
The console is a WebAssembly SPA (a Trunk build artifact), which a plain
cargo build can’t produce. So build it once first, then the binary embeds the
real assets:
just console # builds crates/boatramp-console/dist (needs `nix develop`)
cargo build -p boatramp --release
If you build the binary without first building the SPA, it still compiles — a
placeholder page is baked in instead, explaining how to build the real one. To
leave the console out entirely, drop the default feature:
cargo build -p boatramp --no-default-features --features fs,slatedb.
Where it’s served (defaults + overrides)
| Field | Default | Meaning |
|---|---|---|
enabled | false | Serve the console at all (opt-in). |
host | * | Which Host the console answers on: * (any), an exact host (console.example.com), or a leading wildcard (*.example.com). |
path | /_console | The URL path prefix it mounts at. Kept under the reserved /_ namespace so it never collides with a published site. |
For example, to serve it only on a dedicated admin host at the site root:
console: ( enabled: true, host: "console.example.com", path: "/" ),
The console has a real client-side router, so pages are deep-linkable URLs under
the mount path (e.g. /_console/sites/blog) — a refresh or a shared link lands
on the right page.
Sign in
The static shell loads for anyone who reaches the path, then you authenticate to
the API from inside it — either paste a control-plane token or use OIDC
(if your instance has an issuer configured). Your token’s roles decide what you
can see and do (an admin token sees everything; a scoped token sees only its
sites). Mint one with:
boatramp token create --role admin "console" # or a narrower --role
Security notes
- The console’s static assets are served unauthenticated at the mount path.
They hold no secrets, and every action goes through the token-gated
/api, so a bearer token is still required to do anything. (A bearer token can’t gate a top-level browser navigation anyway — the path is obscurity, the token is the real gate.) - For a management UI, prefer serving it behind TLS and, if you want
network-level gating, on a dedicated
hostyou can firewall or put behind a VPN/reverse-proxy. - Because it’s same-origin with the API, you do not need to add anything to
cors_allowed_origins. (That knob is only for hosting the console — or another browser client — on a different origin.)
See also
- Bootstrap authentication & mint tokens
- Sign in with OIDC
- Cargo features — the
consolebuild feature.
Deploy a handler
Serve a route from an already-built WebAssembly component. A handler is a
function reached by an HTTP route — you declare it
in project.cfg, validate the manifest, then sync, and the sync step validates the
component blob and activates it against the site policy.
To build a component from scratch, see Write your first handler. To use the host bindings from guest code, see Use handler bindings. To run the same kind of component invoked by name instead of behind a route, see Deploy & invoke a function.
Before you start
- A component built to the
wasm32-wasip2target that exportswasi:http/incoming-handler. Sync rejects a component without this export. - The component file reachable from your project root (here,
dist/api.wasm). - A server built with the
handlersfeature. - The site policy enabled (
handlers.enabledon the site) and itsallow_importscovering every import you request — set this in step 3.syncdoes not set it, so a fresh site refuses a handler deployment until you do.
1. Declare the handler in project.cfg
Add the handler to the routing.handlers list. Each entry names a route pattern,
the allowed methods, the component file, and the host imports it may use (sql,
wasi:keyvalue, wasi:blobstore, wasi:messaging, invoke, plus wasi:http /
wasi:io, which every handler gets):
routing: (
handlers: [
( route: "/api/**", component: "dist/api.wasm",
methods: ["GET", "POST"],
imports: ["sql", "wasi:keyvalue"] ),
],
),
A component receives only the imports it declares here, and only those the site
also grants. Unlisted interfaces (for example wasi:filesystem) are refused even
when named.
A handler can also call a sibling top-level function in-process: grant it
invoke and add an invoke_targets allowlist naming the functions it may reach
(deny by default, * wildcards allowed). See
Deploy & invoke a function and
Use handler bindings.
2. Validate the manifest
Check the config shape and route table before you deploy:
boatramp validate
project.cfg: routing OK (1 handler: /api/** [GET, POST])
validate checks the manifest. The component blob itself — parseability, the
wasi:http/incoming-handler export, and the import allowlist — is validated at
sync.
3. Enable handlers on the site
The route you declared ships in the deployment, but a deployment that ships
handlers is refused at activation unless the site permits them. That gate is the
site’s handlers.enabled policy — a piece of
site config separate from the deployment,
and sync never sets it. Skip this step and the sync below fails with:
activation refused: deployment ships handlers/consumers but the site has them disabled
Enable it once with boatramp handlers. List the interfaces your handlers import
(allow_imports must be a superset of every handler’s imports); the example
/api/** handler above imports sql and wasi:keyvalue:
boatramp handlers enable --site my-site --allow sql --allow wasi:keyvalue
boatramp handlers show --site my-site prints the current policy; handlers disable
turns it back off; handlers allow/deny <import>… adjust the allowlist. An import a
handler requests but the site doesn’t grant is refused at activation — see
Use handler bindings.
Or, declaratively, fold the same policy into an apply.cfg config.handlers block
and run boatramp apply — it sets the site policy then deploys + activates, in the
right order, so one command does the whole thing:
sites: [(
name: "my-site",
path: "./dist",
routing: ( handlers: [( route: "/api/**", component: "dist/api.wasm",
methods: ["GET", "POST"], imports: ["sql", "wasi:keyvalue"] )] ),
config: ( handlers: ( enabled: true, allow_imports: ["sql", "wasi:keyvalue"] ) ),
)],
4. Sync the deployment
Upload the component and activate it:
boatramp sync ./dist --site my-site
validated dist/api.wasm — exports wasi:http/incoming-handler, imports OK
activated my-site -> 7f3a2b2c — handler /api/**
If the component requests an import the site does not allow, sync rejects the deployment and the previous one stays live.
5. Call the route
curl https://my-site.example/api/health
{"status":"ok"}
A method outside the handler’s methods list returns 405; a path outside the
route pattern falls through to rewrites, then static content.
Reference
- Route and import fields: project.cfg schema.
- Using bindings from guest code: Use handler bindings.
- Build a handler end to end: Write your first handler.
Compose components into one handler
A handler is a single WebAssembly component. But you often want to author it in
pieces — a resolver here, a middleware there, a shared library of business
logic — each a separate, independently-built component with a typed WIT
interface. boatramp compose fuses those pieces into one linked component,
in-process, so you deploy a single .wasm while keeping the parts separate in
your source tree.
Linking happens at build time and is checked at compile time: a plugin’s exports must match the interface the edge imports, or composition fails. There is no network hop at runtime and no dynamic plugin loading — the fused component is one artifact the runtime instantiates like any other.
The shape: an edge and its plugins
Composition has two roles:
- The edge (root) component exports the handler world (e.g.
wasi:http/incoming-handler) and imports the interfaces its plugins provide. - Each plugin (leaf) component exports an interface that satisfies one of the edge’s imports.
For example, an edge that needs an adder interface and a plugin that provides
it, declared in WIT:
package example:demo;
interface adder {
add: func(a: u32, b: u32) -> u32;
}
// The plugin provides `adder`.
world plugin {
export adder;
}
// The edge needs `adder` and exports the handler entry point.
world edge {
import adder;
export run: func() -> u32;
}
Build each to a component (wasm32-wasip2), then fuse them:
boatramp compose \
--edge edge.wasm \
--plugin adder.wasm \
-o handler.wasm
# composed edge.wasm + 1 plugin(s) -> handler.wasm (… bytes)
--plugin is repeatable — pass one per plugin. The output is a normal component
you deploy through the usual path:
boatramp blob put handler.wasm # content-addressed upload
# …then reference it from a handler route as you would any component.
What stays imported
Composition only satisfies the imports a plugin provides. The fused
component’s exports are unchanged (it still exports e.g.
wasi:http/incoming-handler), and every host import a part declares —
wasi:http, sql, kv, messaging, invoke, graphql, … — stays
imported, for the runtime to supply at instantiation. So composition is purely
about linking your own components together; it never absorbs or hides the
platform capabilities a handler is granted (those still go through the site’s
allow_imports gate as usual).
If a plugin’s exports don’t match any edge import, or a component is malformed,
compose fails with a compose failed: … message and writes nothing.
When to use it
- GraphQL resolvers as plugins. Author each resolver (or a group) as its own component and fuse them into one federation-subgraph handler — see Serve a GraphQL API.
- Reusable middleware. Keep an auth/logging/validation layer as a plugin and compose it onto several edge handlers.
- A shared logic library built once and linked into multiple handlers.
Composition runs entirely in-process (it needs no external wac toolchain) and
never runs on the serving node — it is a build step that emits one component,
exactly like any other artifact you deploy.
Use kv / sql / blobstore / messaging
A handler is a WebAssembly component that runs a dynamic route. It imports only
the host interfaces it declares, intersected with what the site grants — deny by
default. This page covers the four data bindings an operator wires up:
wasi:keyvalue, sql, wasi:blobstore, and wasi:messaging. To ship a
component, see Deploy a handler.
Grant a binding
Each binding a handler uses goes in the imports list of its routing.handlers
entry in project.cfg. Name only what the handler calls; a component that
imports an interface the site does not allow fails validation at sync:
routing: (
handlers: [
( route: "/api/**", component: "api.wasm",
methods: ["GET", "POST"],
imports: ["wasi:keyvalue", "sql", "wasi:blobstore", "wasi:messaging"] ),
],
),
The site’s allowed-imports policy caps this list: a binding you name that the site does not permit is refused at activation.
The four data bindings
wasi:keyvalue— a per-site key/value store. Use it for session state, counters, and small hot records the handler reads and writes on the request path.sql— a libsql database per site. This is a real database per site, not schema separation, so one site’s tables never collide with another’s. Use it for relational data and queries. You can also point a name at your own external Postgres/MySQL — see Bring your own database. For most queries you can build them with the typedormbuilder instead of writing SQL strings — same databases, same transaction, injection-safe.wasi:blobstore— per-site blob storage over the server’sStoragebackend, key-prefixed per site. Use it for uploaded files and generated artifacts too large for the key/value store.wasi:messaging— publish/subscribe and queues. A handler publishes to a topic; a consumer declared inrouting.consumerssubscribes to that topic and processes each message off the request path. Grantwasi:messagingto both the publishing handler and the consuming component, and match the topic name on each side. See Run consumers, crons, and streams.
Read bus stats (messaging-stats)
A component that needs to show how a flow is doing — “is this customer’s sync backed up or
dead-lettering?” — grants the read-only messaging-stats capability and reads the gauges
boatramp already computes for a topic: get(topic) returns { dead_letter_count, backlog, in_flight }, and groups(topic) returns per-consumer-group { group, in_flight, lag }. It is
read-only — there is no claim/redrive/purge here. (Since v0.4.26. With the shim:
messaging_stats::get(topic) / groups(topic).)
It is tenant-scoped host-side, because per-topic depth/DLQ counts are otherwise a cross-tenant oracle:
- A plain topic reads the component’s own private namespace (host-prefixed), exactly like a publish — a component only ever sees its own.
- A
bus:topic must be one the component declared in itsstats_topics, and a per-tenant topic is declared as a template with a{tenant}placeholder (e.g.bus:sync/{tenant}/import). The guest names that template verbatim; the host substitutes this invocation’s resolved tenant into{tenant}before reading — the guest never supplies the tenant, so it cannot name another tenant’s topic. Abus:topic that isn’t a declared template, or a{tenant}template with no resolved tenant, is refused (not-declared, fail-closed); an ungranted component getsaccess-denied.
// A subgraph that shows per-flow depth/DLQ declares the template it will read:
( name: "subgraph-sync",
imports: ["messaging-stats", "graphql"],
stats_topics: ["bus:sync/{tenant}/import"] )
Invoke a sibling function
A handler can call a sibling top-level function in-process,
exactly as a function invokes another. Grant invoke in the handler’s imports,
then list the functions it may reach in an invoke_targets allowlist — deny by
default (an empty list invokes nothing, even with invoke granted), with *
wildcards (*, img-*, or a literal name):
routing: (
handlers: [
( route: "/api/**", component: "api.wasm",
methods: ["GET", "POST"],
imports: ["invoke"],
invoke_targets: ["resize", "thumb-*"] ),
],
),
Like every binding, invoke is capped by the site’s allowed-imports policy: a
site that does not permit it refuses the handler at activation. The callee is
quota-admitted and depth-capped, and the caller’s Authorization header is
forwarded to it unchanged.
Stream a large response
invoke returns the callee’s response whole — the simple default. When a
sibling returns a large or incrementally-produced result, use the streaming
variant instead so the body is never buffered whole in host memory. It hands back
status and headers up front and an incoming-response resource you pull the
body from incrementally:
#![allow(unused)]
fn main() {
let resp = invoke::invoke_streaming("report", &request)?; // same target allowlist
let status = resp.status();
loop {
let chunk = resp.read(64 * 1024)?; // up to N more bytes, blocking
if chunk.is_empty() { break; } // empty ⇒ end of stream
sink.write_all(&chunk);
}
}
Both variants share the exact same in-process path, target allowlist, and
call-depth cap; only the response body’s delivery differs. The request body is
still passed whole (request streaming is a separate step). Streamed responses are
metered at hand-off from a declared Content-Length when present.
Authenticate a browser with a session cookie
A browser app usually holds its session in an HttpOnly cookie its own auth
handler sets — a token JavaScript can’t read (so it survives XSS). boatramp can
treat that cookie as the application bearer for a site, so every handler,
GraphQL query, data-connector read, and sibling invoke sees the caller’s
identity without the app ever putting a token in JavaScript. Opt in on the
site’s handlers config (it’s general — not GraphQL-specific):
handlers: (
enabled: true,
cookie_auth: (
cookie_name: "__Host-session",
// Omit `allowed_origins` for the common case — the app and its API share
// one origin. Only list the *extra* origins a browser app served from a
// **different** origin than this API needs (see CSRF below).
),
)
When set, a request that carries the named cookie but no Authorization
header is authenticated from the cookie value — boatramp injects it as
Authorization: Bearer <value> at the edge, so it flows everywhere a header
bearer already does and is verified byte-identically (your app’s own authorizer /
OIDC config, the data connector’s claims_from_token, the GraphQL field guards).
The Authorization header always wins, so API clients (curl, mobile) are
unaffected.
boatramp only reads the cookie — your app sets, refreshes, and verifies it. The value is an opaque app bearer. Set these attributes on the cookie; two are security requirements, not just advice (boatramp can’t enforce a cookie it only reads):
HttpOnly— unreadable by JS (the whole point; XSS-safe).Secure— HTTPS only.SameSite=Lax(required for CSRF safety). ALaxcookie is withheld on the cross-site POST/fetchan attack would use — the browser half of the defense. UseLax, notStrict:Strictwithholds the cookie when a user arrives from an external link (email, another site), landing them logged-out on first load;Laxstill sends it on that top-level navigation.__Host-name prefix (recommended). It forbids aDomainattribute, so a sibling or parent subdomain can’t set a cookie that shadows yours.
CSRF. A cookie-authenticated request passes the origin check when it is
same-origin — its Origin (or, absent that, Referer) authority equals the
request’s own Host. That covers the normal SPA case (a page calling its own
/graphql) with no configuration: allowed_origins: [] means same-origin
only. A cross-site attacker’s browser sends their origin, never your Host,
so same-origin is definitionally CSRF-safe. allowed_origins then lists only the
extra cross-origins to accept — for a browser app served from a different
origin than this API. A cross-origin request that’s neither same-origin nor listed
is rejected 403. A header-bearer request isn’t CSRF-able (the attacker doesn’t
have the token), so it’s exempt. A request with no Origin/Referer — a
same-origin top-level navigation — is allowed, so a cookie-auth handler must
keep its GET/HEAD side-effect-free (state changes go through POST/etc.,
where the browser sends Origin). If the site also enables the
response cache, never mark a
per-user response public.
Configure the sql backend
The sql binding is the one data binding with a server-side backend choice, set
in the handlers section of boatramp.cfg. Single-node — the default — gives
each site an embedded libsql file under <data-dir>/handlers-sql; omit the sql
key to get this. In a cluster, point every node at one shared sqld, where each
site becomes a namespace, so every node serves the same per-site database:
handlers: (
bindings: (
sql: (
url: "http://sqld:8080",
admin_url: "http://sqld:9090",
token_env: "BOATRAMP_SQL_TOKEN",
),
),
),
For the full field list — including preview_mode and the token env vars — see
the boatramp.cfg schema. The kv, blobstore, and
messaging bindings take no per-binding backend block; they follow the server’s
kv and blobs backends set under serve.
Bring your own database (external Postgres / MySQL)
libsql gives every site a managed, isolated database for free — the right default for multi-tenant data. When you instead want a handler or function to talk to a database you run — an existing Postgres or MySQL, a managed service like Neon / Supabase / PlanetScale — declare it as a named external database. The guest opens it by name through the same interface; only the server config differs.
The sql-postgres / sql-mysql features are in the default build (a
--no-default-features build re-adds them). Declare each database under
handlers.bindings.sql.databases. The connection URL is a secret, so it is named
indirectly through an env var:
handlers: (
bindings: (
sql: (
databases: {
// Opened by the guest as `sql.open("analytics")`.
"analytics": (
kind: "postgres", // or "mysql"
url_env: "ANALYTICS_PG_URL", // secret: postgres://user:pw@host/db
pool_max: 16,
read_only: true, // reject writes at the engine
),
"events": (
kind: "mysql",
url_env: "EVENTS_MYSQL_URL",
read_url_env: "EVENTS_MYSQL_REPLICA_URL", // open-read-only → replica
allow_preview: true, // let preview deployments reach it
),
},
),
),
),
Grant a named database explicitly. The bare sql capability grants only the default
(managed, per-site libsql) database — sql.open(""). A named database needs its own grant: the
handler imports sql:<name> (e.g. sql:analytics) — or sql:* for every name the site exposes —
and the site’s allow_imports must list it too (the site is
the hard ceiling). A handler that opens only analytics imports sql:analytics, and
sql.open("events") from it then fails closed. This is the seam for least-privilege tenant
isolation at the connection level: give the tenant-facing path a normal role (say sql:product)
and any privileged path its own binding (sql:privileged) — each a distinct connection + credential.
On top of that, in-site row scoping is host-forced (see tenant isolation):
when the site declares scoped tenancy the host injects the tenant_id predicate itself, so a missed
WHERE can’t leak across tenants, with Postgres FORCE ROW LEVEL SECURITY a live backstop rather
than resting on app discipline alone.
The guest code is unchanged — the name simply resolves to the external database
instead of a per-site libsql one, and the placeholders stay ?N on every
engine (the host rewrites them to Postgres $N / MySQL ? for you):
#![allow(unused)]
fn main() {
let db = sql::open("analytics")?; // the configured Postgres
let rows = db.query("SELECT id, name FROM signups WHERE country = ?1",
&[Value::Text(country)])?;
}
Raw SQL under scoped tenancy uses the
{scope}marker. If the site/function declares in-site tenancy, place{scope}where the tenant predicate belongs and the host fills it (tenant_id = ?N) from the verified source —... WHERE status = ?1 AND {scope}. A scoped statement that omits the marker is refused (fail-closed), so raw SQL can’t silently skip the tenant boundary. A plain (no-tenancy) app writes ordinary SQL and needs no marker. The typedormbuilder injects the same predicate structurally, no marker needed — see tenant isolation.
Placeholders are always
?1,?2, … — the SQLite-style numbered form — regardless of which engine backs the database. Writing native Postgres$1(or a:nameplaceholder) is rejected, so the same SQL is portable across the managed libsql default and an external Postgres/MySQL. Need a cast for a strict Postgres type? Put it on the placeholder:?1::int.
Keep these properties in mind — they are the deliberate trade-off of pointing at a database boatramp doesn’t manage:
- Isolation is yours. An external database is a single, shared endpoint:
every site/function granted
sql:<name>(orsql:*) and opening the name reaches the same database with whatever that connection’s role can do (it runs arbitrary SQL there). Prefer it for a single-tenant deployment or a genuinely shared database; keep competing tenants’ data on the managed libsql default — or, when the shared DB is multi-tenant, give the tenant path a least-privilege named binding (above) and enforceFORCE ROW LEVEL SECURITY. - Previews are refused by default. A preview deployment can’t open an
external database unless it was declared with
allow_preview: true, so a preview never accidentally writes to your live data. - Values map to the same small vocabulary. Booleans, integers, floats, text,
and blobs round-trip natively; timestamps, UUIDs,
numeric/decimal, and JSON come back as text. A column type outside that set is a clear error asking you to cast it (SELECT col::text). MySQL has no native boolean, so aTINYINT(its bool) reads back as the integer0/1.
Managed SQL on a database boatramp runs
If the Postgres/MySQL is itself a compute workload boatramp runs (see
Run a container or microVM), you don’t have to hand-map a
connection URL at all. Point the database at the workload with compute instead
of url_env, and boatramp wires the rest:
handlers: (
bindings: (
sql: (
databases: {
// Opened by the guest as `sql.open("app")`; backed by the
// compute workload named "pg" that boatramp runs.
"app": (
kind: "postgres",
compute: "pg", // a compute workload, not a URL
database: "app", // db name inside the server
user: "app", // connecting user
// no password_env → boatramp manages the credential
),
},
),
),
),
// Required: a secrets envelope to seal the managed credential at rest.
secrets: ( envelope: "local" ),
With password_env omitted, boatramp fully manages the credential: on first
launch it generates a strong password, seals it with the secrets
envelope, injects it into the pg workload’s server-init env (POSTGRES_* /
MYSQL_*) so the database initializes with it, and connects the handler with the
same sealed password — you set no DB secret anywhere. It then resolves the
workload’s live endpoint per use, so the binding follows the database across
restarts with no config change.
Two requirements make this safe and durable:
- A
[secrets]envelope is mandatory. boatramp refuses to manage a credential it cannot seal, rather than store a DB password in cleartext — a managed database with no[secrets]fails to start with a clear error. (Setpassword_envinstead to bring your own credential for a compute-backed database.) - Give the DB workload a persistent volume. The password is baked into the database on first init, so the data directory must survive restarts for it to keep accepting the same credential. See persistent volumes.
Typed queries with the orm builder
The orm binding is a typed, injection-safe, tenant-scoped query builder over the
same databases as sql — you build a query as a value instead of writing a SQL string,
and the host compiles it to parameterised SQL for whichever engine backs the database
(libsql, Postgres, or MySQL). It rides the sql grant: no separate import token, no
separate backend config. A handler granted sql (or sql:<name>) opens the same database
with orm.open(name) that it would with sql.open(name), on the same transaction — so
you can mix the two freely, and orm.open on an ungranted name fails closed exactly like
sql.open.
It covers the shapes an app realistically uses without dropping to raw SQL: SELECT, INSERT
(multi-row), UPDATE, and DELETE; nested AND/OR/NOT, joins with aliases, aggregates with
GROUP BY/HAVING, BETWEEN/IN/LIKE/IS NULL, ORDER BY, LIMIT/OFFSET; arithmetic + a
portable function set; RETURNING; ON CONFLICT upserts; UNION; CASE (and a boolean/comparison
ORDER BY via CASE); DISTINCT ON (Postgres, fails closed elsewhere); JSON — static-key-path
extract, a bound-key ->>, and jsonb || merge; a narrow correlated roll-up
(related-aggregate), a narrow scalar subquery, and an IN-subquery (all single-named-table,
behind the orm-subquery capability); INSERT … SELECT; pgvector distance/ORDER BY
nearest-neighbour (the experimental orm-vector capability, Postgres-only); and an own-vs-base
preference for base-vs-override reads — own_first() / is_own() (the experimental orm-own-pref
capability, see below). Every value is a bound parameter and every
identifier is validated, so a query cannot construct an injection; an unbounded UPDATE/DELETE
(no filter, no tenant scope) is refused. Reach for raw sql only for what the builder still doesn’t
model — CTEs, window functions, open/free-form nested subqueries.
Tenant scoping is host-forced — you pass no tenant value. If the site (or function) declares
in-site tenancy (tenant isolation), the host injects the tenant predicate
into every query the builder produces — a WHERE tenant_id = ? on reads, an auto-stamped column on
inserts, reaching every joined table and subquery — from the request’s verified source, not from
anything the guest supplies. A plain (single-tenant / no-tenancy) app builds queries unchanged and
nothing is injected. (Pre-0.4 you called open(..).scoped(col, value); that guest-supplied scope is
gone — see tenant isolation.)
#![allow(unused)]
fn main() {
// Same database + transaction as `sql.open("")`; the host folds the tenant scope into every
// query (when the site declares scoped tenancy) — the guest never names it.
let db = orm::open("")?;
let rows = db.query("work_order")
.select([col("id"), col("state")])
.filter(and([
col("project_id").eq(project_id),
or([col("priority").ge(3), col("escalated").eq(true)]),
]))
.order_by_desc("created_at")
.limit(20)
.run()?;
// Under scoped tenancy the host prepends the tenant predicate:
// SELECT id, state FROM work_order
// WHERE tenant_id = ?1 AND (project_id = ?2 AND (priority >= ?3 OR escalated = ?4))
// ORDER BY created_at DESC LIMIT 20
}
Prefer a tenant’s override over the shared base (own_first() / is_own()). On an own+null
read of a two-layer table — a shared base row (tenant_id IS NULL) plus an optional per-tenant
override — you often want the tenant’s own row if present, else the base. Because the tenant
column is host-injected and hidden, you can’t write ORDER BY (tenant_id IS NOT NULL). Instead
own_first() sorts own rows ahead of base ones without naming the column, so the base-vs-override
lookup is just db.query("t").filter(key_pred).own_first().limit(1). The host lowers it, using the
same resolved tenant it injects for the scope, to ORDER BY (CASE WHEN <own> THEN 1 ELSE 0 END) DESC. is_own() is the underlying 0/1 expression (usable in select, or is_own().eq(1) to
keep only overrides). It fails closed without an own-tenant (own/own+null) read, and needs
the experimental orm-own-pref capability — declare requires = ["orm-own-pref"].
The builder is provided by the authoring kit (the
boatramp-uchron-shim compat::orm
module) behind its off-by-default orm cargo feature — turn it on only against a boatramp
that ships the orm interface. See that kit’s authoring guide for the full surface
(inserts, upserts, RETURNING, JSON, subqueries, UNION, CASE, expressions, own_first).
See the boatramp.cfg schema for the full field list and Cargo features for the build features.
Tail guest output with boatramp logs if a binding call traps — see
Observe a running server.
Declare a managed database
A project handler that opens a sql / orm binding needs a database behind it.
boatramp can provision and fully manage that database for you — mint its
credential, seal it, follow the workload across restarts — driven entirely from
the apply manifest. You declare what you want (a Postgres 16, medium-sized,
shared-tenant, with pgcrypto); boatramp provisions it, wires the sql binding,
and never puts the credential in your manifest.
This is the declarative front door onto the daemon-level managed-database stack.
Before v0.6.0 an operator had to hand-edit boatramp.cfg; now a project author
adds a databases: entry and runs boatramp apply.
Before you start
- A boatramp server and a token with
Project·Admin— declaring a database mints owner-role identities (it is the same owner-grade gate as schema migrations and repair). A publisher/deployer token cannot declare one. See Bootstrap authentication & mint tokens. - An
applymanifest for the project. See Declare a project withapply.
1. Declare the database in the manifest
Add a databases: [ … ] block to the manifest. Each entry is a typed, safe
projection of the internal database config — it carries only the fields a project
author may safely set:
(
project: "acme",
databases: [
( name: "app", kind: postgres, version: 16, size: medium,
tenant: shared, tenant_scope: project,
extensions: ["pgcrypto"],
rls_session: true, tenant_guc: "app.tenant_id" ),
],
sites: [
// a site whose handler opens sql.open("app") — bound to the DB above
( name: "www", path: "www/dist" ),
],
)
Databases are reconciled before sites, functions, and compute, so a handler
shipped in the same apply binds an already-provisioned database. The block is
additive — an absent databases: still parses (no version bump, no migration).
Fields you can declare
| Field | Meaning |
|---|---|
name | The binding name — what a handler opens (sql.open("app")). |
kind | postgres or mysql. |
version | The engine major version (e.g. 16). |
size | A small / medium / large preset → bounded vcpus/mem/volume (not raw VM knobs). |
extensions | Postgres extensions to enable (subject to the operator’s trusted-extension allowlist). |
tenant | single (dedicated) or shared (one server, per-tenant isolation). |
tenant_scope | project or site — the granularity a shared server isolates by. |
read_only | Provision a read-only binding. |
rls_session / tenant_guc / session_guc / tenant_all_marker | Row-level-security / session knobs for tenant isolation. |
pool_max / connect_timeout_secs / startup_grace_secs | Connection knobs — each capped to an operator ceiling. |
See the databases: schema for the full
field reference.
Fields you cannot declare — the security contract
These are deliberately not manifest fields; a manifest that names one fails to parse:
image— a manifest can’t name an arbitrary OCI image (RCE).password_env/url_env/read_url_env/migration_url_env— no BYO-secret / SSRF / arbitrary-host references. Omittingpassword_envis exactly what makes a declared database the fully-managed path: boatramp mints and seals the credential, so the manifest never carries or references a secret.path— no host-filesystem traversal.compute— the per-project server workload is derived, never author-named.
2. Apply
$ boatramp apply -f apply.cfg
apply persists the declaration, then eagerly triggers the idempotent provision,
so the database exists by the time apply returns. The credential is minted
and sealed server-side and the sql binding is wired — nothing about the
secret ever crosses the wire in the manifest.
Re-applying is safe and converges: a same-name re-apply consumes no fresh
resource. A re-apply that changes an identity field (kind / tenant /
tenant_scope) of an existing declared database is refused — a silent
data-loss / orphan guard. Removing a databases: entry never drops the
database, its volume, or the credential; teardown stays an explicit imperative
verb (removal is decoupled from the manifest so an accidental deletion can’t
destroy data).
3. Inspect (read-only)
The boatramp db command inspects the project’s declared databases. It is
read-only — there is deliberately no db create: the manifest is the
sole authoring surface (a create verb would compete as a second source of truth).
These reads are Project·Read.
$ boatramp db ls
NAME KIND TENANT SCOPE SIZE
app postgres shared project Medium
$ boatramp db get app # the full declaration
$ boatramp db status app # declaration + derived server-workload handle
database `app`
server workload: <derived-handle>
declared: yes
Add --json to any of them for the raw structured view. To declare, change, or
tear down a database, edit the manifest and apply (or use the imperative
teardown verb) — not this command.
How the declaration merges with node config
A declared database is persisted to a project-scoped, cluster-replicated
control-plane store (under the project/<proj>/ prefix, so a project teardown
reaps it). At serve time, boatramp merges this per-project store with the
node-static [handlers].bindings.sql.databases map at one merge point, where
the node-operator’s static config wins, fail-closed, on a same-name conflict.
A project manifest can never shadow, override, or downgrade an operator’s
bring-your-own binding — the refusal is enforced at the merge point, so even a
node reloading config with both present refuses (not merely the apply CLI).
Limits
Two operator-configurable per-project ceilings guard against a Project·Admin
declaring an unbounded number of databases (each eagerly provisioning a
multi-GiB volume), enforced fail-closed at declare (a 422 before any provision):
handlers.bindings.sql.max_declared_databases— a count (default 16).handlers.bindings.sql.max_declared_volume_mib— an aggregate volume cap (default 512 GiB).
The provisioning is bound to the caller’s project — a manifest can only ever provision onto its own project’s server.
See also
databases:schema- Declare a project with
apply boatramp dbCLI- Isolate tenants within a project
- Use kv / sql / blobstore / messaging
Ingest large uploads over S3 (blob-ingress)
A wasm guest reads blob objects by key through the wasi:blobstore binding, but
it can’t accept a multi-gigabyte upload streamed through the sandbox — and a
browser or a bulk agent shouldn’t have to POST bytes through your handler at all.
Blob-ingress lets a client outside the sandbox upload binary objects
directly into a project’s blob container, authenticated, resumable, at scale,
speaking one protocol — S3 — everywhere. The guest then reads the object by
key through the unchanged wasi:blobstore (has/get) with zero guest
change: every object lands at hblob/{project-qualified-site}/{container}/{key},
the exact prefix the guest already reads.
The client configures any S3 SDK (aws-sdk-*, boto3, rclone, minio-js,
opendal) or a browser fetch() with short-lived, scoped temporary
credentials and uploads directly. There are two realizations, chosen per
backend, but identical client code:
- Local-backed container (
fs, in-memory): boatramp exposes its own S3-compatible endpoint over the storage backend, issues its own STS-style temp credentials, and verifies SigV4 itself. Bytes transit the node (unavoidable for local disk), but the wire protocol is standard S3. - Cloud-backed container (S3 / GCS / Azure): boatramp brokers a native scoped temporary credential (AWS STS session policy, GCS signed URL / STS downscoping, Azure user-delegation SAS). The client talks to the real store; bytes never transit the node.
Two mint surfaces produce those credentials:
- a guest capability (
boatramp:handlers/blob-upload) — a handler or function, under the end-user’s session, mints a credential scoped to one key or a prefix; - an operator CLI (
boatramp blob mint-upload) — for bulk / out-of-band provisioning.
Both are deny-by-default and host-scoped: the project and site are host-forced, never named by the guest; a credential is structurally confined to its origin tenant.
Feature-gated. The local S3 face + both mint surfaces are behind the
blob-uploadcargo feature (on in the batteries-included build). Each cloud broker is behindblob-upload-aws/blob-upload-gcs/blob-upload-azure(orblob-upload-cloudfor all three), off in the default build.
Enable the local S3 face
Add an [serve] listener for the S3 face — a dedicated address, separate
from the control-plane addr, with its own SigV4 auth boundary (it never
reaches the control-plane router or serve_by_host):
[serve]
addr = "0.0.0.0:8080" # the control plane / site edge
s3_ingress_addr = "0.0.0.0:9000" # the dedicated S3 face
# A raw 32-byte file — the dedicated HKDF root that derives every credential's
# secret_access_key. MUST be the SAME file on every node in a cluster (the face
# refuses to start on a multi-node deployment without an explicit, uniform one).
s3_ingress_secret_file = "/etc/boatramp/s3-ingress.key"
# The publicly-reachable base URL the client SDK / a browser targets. Absent ⇒
# derived from s3_ingress_addr as http://<addr> (fine for localhost/dev; set the
# real public URL in production, e.g. behind a TLS terminator).
s3_ingress_public_url = "https://uploads.example.com"
# Operator ceilings the mint clamps down to (a guest can only narrow):
s3_ingress_mint_max_ttl_secs = 3600 # cred TTL ceiling (default 1h)
s3_ingress_mint_max_bytes = 104857600 # per-cred max object size (100 MiB)
Generate the secret once and copy it to every node:
$ head -c 32 /dev/urandom > /etc/boatramp/s3-ingress.key
$ chmod 600 /etc/boatramp/s3-ingress.key
The local face signs under a fixed SigV4 region boatramp and service s3,
uses path-style addressing (/{container}/{key}), and is write /
multipart-only — there is no external GET / LIST / DELETE (it is an ingress
surface, not a data-exfil one; read stays guest-only).
Grant the guest mint capability
A guest mints only what its component is granted. Two independent rights:
blob-upload:write— single-shotPutObjectcredentials;blob-upload:multipart— the multipart quartet (create / upload-part / complete / abort).
Bare blob-upload is not a grant, and there is no blob-upload:*. Grant a
right in the site’s handler config, and list the containers the component may
mint for (empty ⇒ deny-all):
// a site's [handlers] handler config
(
route: "/api/*",
component: "api.wasm",
imports: ["blob-upload:write", "blob-upload:multipart"],
// Per-component container allowlist — least-privilege, mirrors
// tenant_secret_names. A container not listed here is access-denied.
upload_containers: ["avatars", "bulk-ingest"],
)
A site handler mints for its own host-routed site. A standalone top-level function has no single routed site, so it names the site it mints for in its own config — host-forced, never guest-supplied — and the host validates that site belongs to the function’s project before minting:
// a top-level function config
(
imports: ["blob-upload:multipart"],
upload_containers: ["bulk-ingest"],
// The site this function mints for. The host validates it exists in the
// function's (host-forced) project; unset, or a site not in the project ⇒
// fail-closed (no binding is attached, every mint is no-resolved-site).
blob_upload_site: "app",
)
In both cases the project is host-forced from the invocation and the site is host-forced (routed, or config-declared + project-validated); the guest can override neither, and the WIT surface has no project/site parameter.
Recipe 1 — browser UGC (presigned PUT, single key, content-type)
The single-key / PUT-only shape returns a presigned PUT URL — no SigV4 in the
browser, one fetch(). A handler under the user’s session mints it:
#![allow(unused)]
fn main() {
// inside a wasm handler (imports "blob-upload:write"). The exact generated names
// come from the `boatramp:handlers/blob-upload` WIT via the guest bindings; the
// shape below is faithful (WIT kebab-case maps to snake_case in Rust).
let creds = mint(&MintRequest {
container: "avatars".into(),
// Exactly one object key — the browser-UGC shape. Create-only by default.
target: UploadTarget { key: Some(format!("users/{user_id}/{uuid}.jpg")), prefix: None },
perms: vec![UploadPerm::Put],
constraints: UploadConstraints {
max_bytes: Some(5 * 1024 * 1024), // ≤ 5 MiB (clamped to the ceiling)
content_type: Some("image/*".into()), // an exact type or a type/* family
require_sha256: false,
create_only: true, // refuse to overwrite (default for a key)
},
ttl_seconds: 300, // 5 min (clamped to the ceiling)
})?;
}
The handler returns the presigned form to the browser, which uploads with a single request:
// creds is the presigned-put variant: { url, method, required_headers, expires_in_secs }
await fetch(creds.url, {
method: creds.method, // "PUT"
headers: Object.fromEntries(creds.required_headers), // e.g. Content-Type: image/jpeg
body: file,
});
The guest then reads it back by key with the unchanged blobstore binding:
blobstore.get_container("avatars")?.get_data(&format!("users/{user_id}/{uuid}.jpg"), ..).
Recipe 2 — bulk agent (prefix temp-credentials, multipart)
The prefix / multipart shape returns STS-style temp credentials — feed them
verbatim to any S3 SDK. Mint from the operator CLI (or a guest with
blob-upload:multipart):
$ boatramp blob mint-upload \
--site app --container bulk-ingest \
--prefix "imports/2026-09/" \
--perms multipart,put \
--ttl 3600 \
--emit env
# ── an env block for an S3 SDK ──
export AWS_ACCESS_KEY_ID=BRUP...
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=...
export AWS_ENDPOINT_URL=https://uploads.example.com
export AWS_REGION=boatramp
export AWS_S3_FORCE_PATH_STYLE=true
--emit selects the output form: env (a shell export block), aws (an
~/.aws/credentials profile), rclone (an rclone remote block), json (the raw
credential), or the default human table + env block. A premises agent then
writes thousands of keys with the normal SDK and native S3 multipart for
resume:
$ aws s3 cp ./big-archive.tar s3://bulk-ingest/imports/2026-09/archive.tar \
--endpoint-url "$AWS_ENDPOINT_URL" # aws-cli does multipart automatically for large files
On a local container the face assembles multipart parts server-side (parts
stage under the reserved .boatramp-uploads/{uploadId}/ namespace a client key
can never collide with, GC’d on abort/expiry; Complete is all-or-nothing). On a
cloud container the client drives the store’s native multipart directly.
Recipe 3 — content-addressed upload (idempotent, replay-inert)
Set --sha256 (require_sha256) so the object key must equal
sha256(bytes). On a local container the face verifies the hash as-streamed
and rejects a mismatch (BoatrampSha256Mismatch); a retried or replayed upload
of the same bytes is an idempotent no-op, and different bytes can never land at
the declared key. This is the mandatory strong enforcement across clouds (a
cloud session policy / SAS generally can’t cap object size or content-type, but
content-addressing is enforceable everywhere the store pins the hash):
$ boatramp blob mint-upload \
--site app --container artifacts \
--key "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" \
--sha256 \
--content-type "application/octet-stream" \
--emit json
Because the key is the digest, an upload is replay-inert: replaying the signed request writes the same bytes to the same key — a no-op. Content-addressed mode is why a long-TTL bulk credential is safe even though a stateless SigV4 request can be replayed within its TTL.
Enforced vs advisory constraints
A temp-credential’s response carries an explicit enforced and
advisory list. This is a trust contract, not decoration:
- The local face enforces everything (size, content-type, sha256, create-only) — every stamped constraint is a hard, fail-closed check.
- A cloud broker labels a constraint it cannot cap in-policy as
advisory— a session policy / SAS / signed URL binds the prefix and the actions, but generally cannot cap object size (and can’t pin content-type on a broad prefix).require_sha256is alwaysenforcedwhere the store pins the hash and is the recommended way to make size effectively moot.
Never treat an advisory constraint as a guarantee. Prefer content-addressing
for cloud bulk credentials.
Per-cloud operator setup
Enable the cloud broker feature for your store (blob-upload-aws,
blob-upload-gcs, blob-upload-azure) and configure [serve.s3_ingress_cloud].
The client SDK usage and both recipes are identical — only the operator setup
differs.
AWS
boatramp brokers sts:AssumeRole (default) with an inline session policy
resource-scoped to the exact hblob/{qualified-site}/{container}/… prefix and
action-scoped to s3:PutObject + the multipart quartet only (no get / list /
delete / bucket-level). Use sts:GetFederationToken for an IAM-user deployment.
[serve.s3_ingress_cloud]
# The role boatramp's base credential assumes (its trust policy must allow the
# base principal to assume it). Grant it s3:PutObject + CreateMultipartUpload +
# UploadPart + CompleteMultipartUpload + AbortMultipartUpload on the bucket.
aws_role_arn = "arn:aws:iam::123456789012:role/boatramp-blob-ingress"
# IAM-user base credential with no role to assume ⇒ GetFederationToken instead.
# aws_use_federation_token = true
The role’s trust policy must let boatramp’s base principal assume it:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:role/boatramp-node" },
"Action": "sts:AssumeRole"
}]
}
A single-key / browser mint returns a per-object presigned PUT (content-type enforced when signed into the request); a prefix / multipart mint returns temp credentials via the STS session policy.
GCS
The single-key shape is a per-object V4 signed PUT URL via IAM signBlob
(keyless, Workload-Identity-friendly). The prefix shape is a hand-rolled STS
Credential-Access-Boundary token-exchange against
https://sts.googleapis.com/v1/token, scoped with a CEL condition
(resource.name.startsWith('…/{hblob-prefix}')) and the single role
roles/storage.objectCreator (object-create only — no viewer/admin).
Grant the boatramp service account roles/iam.serviceAccountTokenCreator (on
itself, so it can signBlob and mint the STS token):
$ gcloud iam service-accounts add-iam-policy-binding \
boatramp@PROJECT.iam.gserviceaccount.com \
--member="serviceAccount:boatramp@PROJECT.iam.gserviceaccount.com" \
--role="roles/iam.serviceAccountTokenCreator"
# and the object-creator role on the bucket:
$ gcloud storage buckets add-iam-policy-binding gs://YOUR_BUCKET \
--member="serviceAccount:boatramp@PROJECT.iam.gserviceaccount.com" \
--role="roles/storage.objectCreator"
[serve.s3_ingress_cloud]
gcs_client_email = "boatramp@PROJECT.iam.gserviceaccount.com" # absent ⇒ from ADC
Azure
A user-delegation SAS (AAD, no account key). The boatramp identity needs the
Storage Blob Delegator role (to obtain a user-delegation key) plus a
write/create role such as Storage Blob Data Contributor on the container.
An Azure SAS scopes to a single blob or a directory prefix — but a
directory-scoped SAS only confines on a hierarchical-namespace (ADLS Gen2)
account; on a flat account it silently widens to container-wide. So a prefix
mint requires you to declare the account has HNS with azure_hns = true
(fail-closed otherwise):
[serve.s3_ingress_cloud]
azure_account = "mystorageacct"
# azure_service_url = "https://mystorageacct.blob.core.windows.net/" # else derived
# REQUIRED for a prefix credential: the account has a hierarchical namespace.
azure_hns = true
$ az role assignment create \
--assignee "$BOATRAMP_PRINCIPAL_ID" \
--role "Storage Blob Delegator" \
--scope "/subscriptions/.../storageAccounts/mystorageacct"
$ az role assignment create \
--assignee "$BOATRAMP_PRINCIPAL_ID" \
--role "Storage Blob Data Contributor" \
--scope "/subscriptions/.../storageAccounts/mystorageacct/blobServices/default/containers/YOUR_CONTAINER"
Testing against Azurite. The Azure blob-ingress live gate runs against the Azurite emulator. Start it with
azurite --skipApiVersionCheck— the 1.x Azure SDK sends a newerx-ms-versionthan the emulator’s default allowlist, so without that flag Azurite rejects the request with a version error.
CORS (browser uploads)
The local S3 face is a credentialed write endpoint, so it never reflects an
arbitrary Origin and never answers *. Set a first-class per-container
allowlist so the face answers the browser’s OPTIONS preflight for exactly your
origins. On a cloud target you set the bucket’s own CORS (the mint/CLI warns
when a cloud target lacks a rule for the intended origin).
Error codes (local face)
The local face returns standards-shaped S3 error XML with a stable, greppable
<Code> vocabulary. Auth failures deliberately all collapse to a uniform
AccessDenied (no which-check oracle); the rest name a specific, non-oracle
condition:
<Code> | HTTP | Meaning |
|---|---|---|
AccessDenied | 403 | The uniform auth/authz refusal (bad signature / scope / expiry / token / revocation — no oracle) |
BoatrampScopeEscape | 403 | The composed key escaped its scoped prefix (traversal / absolute / reserved namespace) |
BoatrampCredExpired | 403 | The credential (session token) is expired |
BoatrampOperationNotPermitted | 403 | An operation the credential’s perms do not grant (e.g. multipart on a put-only cred) |
BoatrampSha256Mismatch | 400 | A content-addressed upload’s bytes did not hash to the declared key |
BoatrampContentTypeRejected | 400 | The Content-Type did not satisfy the required constraint |
BoatrampMultipartInvalid | 400 | Malformed multipart (bad part list, unknown uploadId, bad part number) |
MalformedRequest | 400 | Malformed request body / framing (bad XML, bad chunk framing) |
BoatrampSizeExceeded | 413 | Object exceeded the credential’s max_bytes or the per-container ceiling |
BoatrampOverwriteDenied | 412 | A create-only credential attempted to overwrite an existing key |
BoatrampQuotaExceeded | 429 | A DoS cap was hit (too many parts / concurrent uploads / staged bytes) |
MethodNotAllowed | 405 | The method/route is not one the face implements |
InternalError | 500 | A storage-backend fault (native S3 code so SDKs retry) |
A cloud container returns the store’s native S3 / GCS / Azure error codes, not this vocabulary — the client talks to the real store.
Security model (why a credential can’t escape its scope)
- Project AND site are host-supplied, never guest-supplied. The WIT surface has no project/site parameter; the credential is structurally confined to its origin tenant.
- The secret is HKDF-derived from a dedicated, independently-rotatable
ingress root (never the
[secrets]KEK, never the COSE signing key). The root never leaves the host; a leaked temp cred is bounded to its scope and short TTL. - SigV4 is verified fail-closed and constant-time on the local face; expired / malformed / tampered ⇒ a uniform 403, no partial write.
- Deny-by-default everywhere: no grant ⇒ no binding; an empty
upload_containers⇒ deny-all; a zero TTL ceiling disables minting. - Keys are traversal-screened at the mint choke point (covers cloud too) and
re-anchored under the host-forced
hblob/…prefix — an object can never land outside its scoped prefix.
Isolate tenants within a project
boatramp has two tenant boundaries, and they stack:
- Project = database. A project maps 1:1 to its own managed database(s). One project can never see another’s rows — the project id is an un-escapable key prefix, so cross-project isolation is structural, not a check you can forget. A single-tenant app needs nothing on this page: the project boundary is the whole isolation.
- In-site sub-tenancy (this page). When one project’s database holds rows for
many tenants — a SaaS whose customers each get a storefront, a portal serving many
organizations — you discriminate them with a tenant column (
tenant_id,org, …) and scope every query to the caller’s tenant. This is opt-in, declared, and host-forced.
The one rule that makes it safe: the guest never supplies the tenant
Under boatramp v0.4.0 the tenant value is resolved host-side, from a verified
source, and injected into every query the guest runs. A handler cannot pass, spoof, or
forget it. There is no WHERE tenant_id = ? for an app author to get wrong and no
guest-supplied scope to audit — a missed predicate cannot leak across tenants because the
predicate isn’t the guest’s to write.
Pre-0.4 the guest called
open(db).scoped(column, value)and supplied the value itself. That API is gone; see Migrating from pre-0.4.
An app declares tenancy along three independent decisions.
Dimension 0: opt in (or explicitly out)
A sql/orm-using function or site carries a tenancy decision:
scoped— in-site sub-tenancy is on; the host scopes every query (below).disabled— deliberately no in-site tenancy; plain queries, the project=database boundary is the whole isolation. This is the explicit single-tenant declaration.- undeclared (no block at all) — under the
single-tenant/devposture this is treated asdisabled; undermulti-tenantit is refused at activation. Therequire_tenancy_declarationposture knob (on undermulti-tenant) forces the decision to be a reviewed choice, never an accidental omission — the class of bug where a developer simply never thought about tenancy.
Dimension 1: the tenant source
scoped tenancy names how the host resolves “own” — every source is host-verified
and bound once per invocation:
| Source | How “own” is resolved | Use for |
|---|---|---|
token | A verified JWT claim (default tid) on the app’s own bearer token, checked against a configured JWKS/issuer. | Authenticated console/portal paths. |
domain | The routed request domain’s context tag (domains.contexts). | Storefronts / public-render paths — one deployment, many customer domains, no app-side Host→tenant lookup. |
signed_context | A host-verifiable signed context stamped on an async job/message. The producer host-stamps its own verified tenant at publish; a consumer declaring sources: [(kind: "signed_context")] resolves that tenant on the async lane (verified against the fleet anchor). A forged/expired/absent context resolves nothing, so an “own” op fails closed. Wired since v0.4.3. | Message consumers, cron, webhooks, workflow steps, fan-out workers. |
none | There is no “own” tenant (truly anonymous / HMAC-webhook auth). Only the null/all access modes are meaningful; an “own” mode fails closed. | Funnel reads, unauthenticated webhooks. |
For the token source, configure the JWKS/issuer that verifies the app bearer with a
token_claims block (issuer + jwks_env or jwks_url, optional audience). boatramp
verifies signature / iss / exp (algorithm pinned to the JWKS key) and a missing or bad
token denies. This is the same verifier the GraphQL data connector uses.
Deriving the tenant key from a claim (the claim transform)
By default the token source injects the chosen claim’s value verbatim as the tenant key.
Real IdP tokens rarely carry the tenant in the exact form you want: Salesforce, for example, puts
the org id inside the sub identity URL (https://login.salesforce.com/id/<orgId>/<userId>),
and when you federate several IdPs you want a per-issuer namespace (sfdc:<orgId>, auth0:<sub>)
so keys never collide. The token source can optionally derive the key from the verified claim:
# template-primary (recommended) — pull one path segment out of the SF `sub` URL, namespace it
sources: [(kind: "token", claim: "sub",
extract: (syntax: "template", pattern: "https://login.salesforce.com/id/{tenant}/{_}"),
namespace: "sfdc")] # ⇒ tenant key "sfdc:00D5f0000000abcEAA"
# regex escape hatch — one named capture, for a tenant embedded in a non-path string
sources: [(kind: "token", claim: "sub",
extract: (syntax: "regex", pattern: "/id/(?<tenant>[0-9A-Za-z]+)/"),
namespace: "sfdc")]
- Template (recommended):
{tenant}captures exactly one delimiter-bounded segment;{_}matches-and-ignores one segment; everything else is literal; the whole value is matched. The greedy / unanchored / multi-group mistakes you can make in a regex are simply unwritable here. - Regex (escape hatch): exactly one named capture
(?<tenant>…)— no numeric group. namespace: a short operator slug; the host joins it to the extracted segment with a reserved:delimiter (you never write the:). Because the delimiter can appear in neither the namespace nor the extracted segment,namespace:segmentis collision-free by construction — two issuers can never derive the same key.
It runs only on the verified claim (post-JWKS/iss/exp), is deny-by-default (a missing or
non-string claim, a non-matching or empty extraction, or a derived key that fails the key-safety
screen resolves no tenant — the scoped op fails closed, never falls back to unscoped or to the
verbatim claim), and is backward compatible (no extract/namespace ⇒ today’s verbatim
behavior). Everything statically checkable is rejected at boatramp apply (an uncompilable regex, a
nullable or multi-group capture, a malformed template, a non-slug namespace, or a route that mixes a
namespaced source with an un-namespaced one). It is not a scripting engine — one bounded pattern,
evaluated host-side.
Debug it offline with the dry-run, which runs the host’s real derivation:
$ boatramp tenancy test-extract --value 'https://login.salesforce.com/id/00D5f0000000abcEAA/0055' \
--template 'https://login.salesforce.com/id/{tenant}/{_}' --namespace sfdc
resolved tenant key: sfdc:00D5f0000000abcEAA
A non-matching value prints the exact deny stage (the same taxonomy the host logs as
outcome=extract_no_match / derived_key_rejected, with a transform_denied counter) — so a
claim aimed at the wrong field, or a wrong pattern, is a 30-second check, not a stream of
silently-denied requests.
Trust note. The transform moves the tenant key from “a single claim value” to “an extraction over a claim” — so point
claimat an issuer-authoritative field (Salesforce’ssub), never a user-editable attribute, and bind eachnamespaceto one verified issuer. Review each transform as a security change.Scope. The transform applies to the
tokensource (handler / plain-wasm routes and the asyncpresent-tokenlane — a presented token seals the derived key, so thesigned_contextconsumer agrees). It does not change the GraphQL data connector’srow_filter, which binds the claim verbatim; do not scope the same tenant column through both a transformedtokensource and a GDCrow_filter, or the two surfaces will key the same principal differently.
Dimension 2: the access mode, per read/write axis
scoped tenancy carries a separate access mode for the read and write axes.
Cross-tenant is default-deny:
| Mode | Rows reachable on this axis |
|---|---|
none | none (deny this axis entirely) |
null | only the shared baseline (<column> IS NULL) |
own (default) | only the resolved tenant |
own_or_null | the resolved tenant plus the shared baseline |
all | every tenant — cross-tenant |
allneeds the operator’s blessing. It is gated by theallow_cross_tenant_dbposture knob — off undermulti-tenant, where anallrequest is silently capped toown. A guest can ask for cross-tenant reads and still get only its own rows unless the operator opted in.own/own_or_null/nullneed no ceiling.own_or_nullon the write axis degrades toown— a write never lands in the shared (NULL) baseline. A common shape isread: own_or_null, write: own: read your own rows plus shared defaults, write only your own.- A mode that needs an “own” value (
own,own_or_null) with an unresolvable source (e.g. no token present) fails closed — it never falls back to unscoped.
Base-vs-override reads (
own_first()). A commonown_or_nullshape is a two-layer table: a shared base row (tenant_id IS NULL) plus an optional per-tenant override, where a lookup wants the override if present, else the base. Since the tenant column is host-injected and hidden, theormbuilder exposesown_first()(sort own rows ahead of base —ORDER BY is_own DESC) andis_own()(a0/1own-ness expression) so you can express this without namingtenant_id. Host-resolved from the same scope, fail-closed off an own-tenant read; needs theorm-own-prefcapability. Raw SQL keeps using its ownORDER BYwith the{scope}marker.
Where you declare it
Tenancy lives at two grains, and the domain source is wired in a third place:
- Site ceiling —
SiteConfig.handlers.tenancy. The maximum a site’s handlers may reach. PUT with the rest of site config. - Per component — the
tenancy(+token_claims) block on a top-level function, a route handler, or a bus consumer inapply.cfg(since v0.4.7). A per-handler value narrows within the site ceiling; it can never widen it (a widening — e.g.disabledremoving scoping under ascopedceiling — is refused fail-closed at bind). This lets one site host, say, apayments.wasmhandler atallbeside aportal.wasmhandler atown. - Domain context tags —
domains.contextsin site config supplies the value for thedomainsource: host-or-wildcard → an opaque tenant tag.
The wire shape is a tagged mode. In the admin API / SiteConfig it is JSON:
{ "mode": "scoped",
"column": "tenant_id",
"sources": [{ "kind": "token", "claim": "tid" }],
"read": "own_or_null",
"write": "own" }
In apply.cfg / project.cfg (RON) it is the byte-identical, fully-quoted form —
the enum tags/values are quoted strings, not bare identifiers (column is required for
scoped):
tenancy: (mode: "scoped", column: "tenant_id", sources: [(kind: "token", claim: "tid")], read: "own_or_null", write: "own")
tenancy: (mode: "disabled")
An async worker resolves its own tenant from the producer-stamped context:
tenancy: (mode: "scoped", column: "tenant_id", sources: [(kind: "signed_context")], read: "own", write: "own")
An unscoped all route under an own site (authorized exception)
Sometimes one route on an otherwise own-ceilinged site must legitimately reach every tenant —
an M2M /token endpoint that validates a client credential across the fleet, an internal /svc/*
admin, a payment webhook. A per-route tenancy normally may only narrow within the site ceiling, so
read: all on an own site is refused. Rather than smuggle the broad reach into a top-level all
function (which hides what is broad behind a function: indirection), declare the exception
inline and greppably — under a three-key model where no single actor, and no single line,
reaches all:
-
The site owner opts the site in:
SiteConfig.handlers.allow_ceiling_exceptions = true. Defaultfalse; whilefalse, every route’s exception token is inert. A site left at the default is provably exception-free without scanning its routes. -
The deployer marks the specific route with
exceed_site_ceiling: trueon itsscopedtenancy:# apply.cfg — one route deliberately broader than the site ceiling (route: "/token", methods: ["POST"], component: "token.wasm", imports: ["sql"], tenancy: (mode: "scoped", column: "tenant_id", sources: [(kind: "token", claim: "tid")], read: "all", write: "all", exceed_site_ceiling: true)) -
The operator must still permit crossing tenants at all — the
allow_cross_tenant_dbposture. With it off, an authorizedallroute is clamped toownat runtime (andapplywarns you it will be).
The exception is deliberately narrow: it can only widen the read/write access mode (up to all)
of a scoped route on the same tenant column. It can never remove scoping (disabled), switch to
the target axis, or change the column — those would escape the operator posture backstop, so they stay
refused. exceed_site_ceiling: true is a separate field from read/write on purpose: a bare
read: all without it still fails closed, so a config typo never silently widens.
A widening that lacks either deployer key is refused at deploy with a message naming the route and
the exact fix (not an opaque runtime error), and boatramp apply --dry-run flags every route that
declares an exception:
⚠ route "/token" [POST]: exceeds the site tenancy ceiling (authorized via `exceed_site_ceiling`;
the site must set `allow_ceiling_exceptions`, and an `all` grant also needs the operator posture
`allow_cross_tenant_db`).
The /graphql gateway is its own route — a token on /token never widens the gateway (or any
sibling); each route carries its own exception.
Migrating from an all-ceilinged site
If you set the whole site to all just to allow one broad route, tighten it: flip the site ceiling to
own, set allow_ceiling_exceptions = true, and add exceed_site_ceiling: true to only the routes
that need it. Every other route is now provably confined to its own tenant, and the broad ones are
greppable in one place.
Both query surfaces are scoped the same way
Whichever way a handler queries, the host applies the same tenant predicate:
-
The
ormbuilder folds the scope in structurally — intoWHERE/HAVING, every joined table (qualified per alias),INSERTrows andINSERT … SELECTsources,RETURNING,UNIONbranches, and narrow subqueries. You write the query; the tenant clause appears in the SQL by construction. Nothing to add, nothing to miss. -
Raw
sqluses a{scope}marker. Put{scope}where the tenant predicate belongs and the host replaces it with<column> = ?N(bound to the verified value) for the axis the statement implies (a leadingSELECTuses the read axis;INSERT/UPDATE/DELETEthe write axis):SELECT id, total FROM orders WHERE status = ?1 AND {scope} DELETE FROM orders WHERE id = ?1 AND {scope}Under
scopedtenancy a statement that omits{scope}is refused before it reaches the database (fail-closed) — you cannot accidentally run an unscoped raw query. When tenancy isdisabled/undeclared, a{scope}you leave in is harmlessly replaced with1 = 1, so the same SQL is safe either way.
Writing a genuinely-global table from a scoped route
A scoped route reads its own rows and, by default, writes only its own rows. A table
declared { "kind": "unscoped" } (global reference data like countries) is
globally readable but write-deny-by-default — a shared-data write is a cross-tenant
blast, so the host refuses it. The old workaround, write: "all", is the wrong fix: it
co-widens reads to every tenant and breaks your isolation.
The right shape (since #503): keep read: "own" and open only the write of a
genuinely tenant-less table — an OAuth CSRF oauth_state, a cross-tenant counter, a
webhook idempotency-key table — where the row has no tenant dimension at all. Reads are
unaffected either way.
The one-axis decision rule — pick by who writes the table:
- Few / sensitive writers → keep the table plain
unscopedand list it per route (unscoped_writes). This is the recommended default (least-privilege): only the routes you name may write it; every other route still gets the read-only-reference contract. - Genuinely-global / many writers → declare the table write-global once
(
{ "kind": "unscoped", "writable": true }). Anyscopedroute may then write it unstamped — one declaration, no per-route bookkeeping.
Mental model: writable / unscoped_writes open a table’s write with no tenant
stamp, through the typed orm binding
only — they never touch reads, and a target route can never use them.
Both mechanisms are OR’d: a write is allowed if the table is write-global or the route lists it. A write is stamped/refused freshly per write — if you later re-declare a listed table as a tenant table, the list entry goes inert and the write is tenant-stamped as normal (a listed table can never be written unstamped once it stops being global).
Global writes go through the
ormbinding, not rawsql. The unstamped global write is anorm-only capability. On the rawsqlsurface there is no write-global exemption: a raw-SQL write to a global table is treated like any other scoped write — it requires the{scope}marker (an unmarked write is refused) and the injectedtenant = ?predicate scopes it to your own tenant. This is deliberate: a raw-SQL statement is opaque text, and a comment-based redirect (e.g. a MySQL/*! … */version-comment) could hide a cross-tenant write from the host’s parser. Theormbinding names the table as a typed value (nothing to hide) and automatically scopes anINSERT … SELECTsource, so it is the safe — and only — path for an unstamped global write. If you need to write a global table, use theormbinding.
Recipe 1 — OAuth /start (per-tenant config read + a global CSRF write)
The canonical case: read per-tenant provider config (read: "own") and INSERT a genuinely
global CSRF state row via the orm binding (the shared callback recovers the tenant
from state, so the table has no tenant column). Keep oauth_state plain and list it on the
route (least-privilege):
// project tenancy schema
{ "default_tenant_key": "tenant_id",
"tables": {
"oidc_provider": { "kind": "tenant" },
"oauth_state": { "kind": "unscoped" } } }
// the /start route: reads its own config, writes the one global table
tenancy: (mode: "scoped", column: "tenant_id",
sources: [(kind: "token", claim: "tid")],
read: "own", write: "own",
unscoped_writes: ["oauth_state"])
Recipe 2 — a global counter written by many routes
A cross-tenant metrics counter every route bumps. Many writers ⇒ declare it write-global once, no per-route list:
{ "tables": { "global_counter": { "kind": "unscoped", "writable": true } } }
Any scoped route may now update global_counter unstamped through the orm binding;
a route reading it still reads globally, and its own tables stay own-scoped. (A raw-SQL
UPDATE global_counter … is still marker-scoped to the route’s own tenant — global writes
are an orm-binding capability.)
Recipe 3 — a webhook consumer with a global idempotency-key table
A bus consumer dedupes deliveries on a shared idempotency_key table. Consumers carry
tenancy too, so list it on the consumer:
consumers: [( topic: "bus:webhooks",
component: "webhook.wasm", imports: ["sql"],
tenancy: (mode: "scoped", column: "tenant_id",
sources: [(kind: "signed_context")],
read: "own", write: "own",
unscoped_writes: ["idempotency_key"]) )]
The operator-trust residual
The host cannot verify a table declared global is truly tenant-less — it takes the
operator’s word. A misdeclaration (marking a table that really does carry per-tenant
rows as writable: true, or listing it in unscoped_writes) lets any granted route
write across tenants, unstamped. So treat a write-global declaration as a security
decision: only ever open the write of a table with no tenant dimension. boatramp tenancy apply prints the write-global tables so you can review exactly which shared tables
are openable. A target route (another tenant’s public subset) can never write a global
table — both opt-ins are refused on the target axis.
The exemption is scoped to the orm binding on purpose. The raw sql binding is opaque
text the host would have to parse to know which table a write targets, and a parser can be
fooled (a MySQL/MariaDB /*! … */ version-comment the engine executes but a parser skips
can redirect the write to a different table). Rather than trust that parse for a
security-critical allow decision, boatramp gives the raw path no write-global exemption at
all — a raw-SQL write to a global table is marker-scoped to your own tenant or refused — and
routes all unstamped global writes through the injection-immune orm binding.
Apply-time validation is fail-fast: unscoped_writes entries are cross-checked against the
stored schema — an unknown table, or one that resolves to a tenant kind, is a 422
(the list can never write a tenant table unstamped anyway); a redundant entry (the table is
already writable: true) is a warning.
Invoke chains carry the tenant, host-side
When a function invokes a sibling (the invoke capability), the
caller’s resolved tenant value rides along host-side — the sibling does not
re-resolve from a request it never saw, and the caller cannot inject a different value. The
sibling then applies its own declared modes over that inherited value (posture-capped
as usual). Background paths with no caller tenant (a cron, a queue drain) fail closed for
an own mode rather than run unscoped.
Async lane: stamp the tenant an emitter verified in-guest (present-token)
A message consumer resolves its own tenant on the async lane from a signed_context
source — but only if the producer stamped one on the message. The host stamps the
producer’s own tenant automatically when it resolved one (a request bearer, a routed
domain). When the emitter’s tenant authority is verified in-guest instead — an app JWT
in a POST body, a portal cookie’s bearer — it hands the credential to the host with the
tenancy capability (since v0.4.7):
#![allow(unused)]
fn main() {
// The emitter declares `imports: ["tenancy"]` + a `token` source + `token_claims`.
boatramp::handlers::tenancy::present_token(&handoff_jwt)?; // host RE-verifies, extracts the tenant
emit::message("bus:handoff.confirmed", &payload)?; // now carries that tenant's context
}
The host re-verifies the presented token against the component’s declared token_claims
(JWKS / issuer / audience / expiry) and stamps the extracted tenant — the guest never names
a tenant value; it can only cause a stamp for a tenant it holds a validly-signed token for.
Deny-by-default (no grant / no token_claims / an invalid token stamps nothing).
Cross-tenant reads: target fields (another tenant’s public subset)
Reading another tenant B’s public subset (an embed, a storefront funnel, a handoff)
is the separate target axis, declared on a GraphQL field with
@tenant(scope: target, via: […], public: …). Since v0.4.7 the external /graphql gateway
serves these on wasm subgraphs too (reads and writes), resolving B per fetch from
domain / capability / handle and confining the subgraph’s own sql/orm to
tenant = B AND <public subset>. See
Cross-tenant target fields for the target-field model.
Include the shared baseline: target_or_null
scope: target_or_null is the target-axis analog of own_or_null (Dimension 2): it widens a
target read from tenant = B AND <public subset> to
(tenant = B OR tenant IS NULL) AND <public subset> — tenant B’s public rows plus the
shared NULL-tenant baseline (catalog defaults, reference data, seeded rows every tenant
shares). Use it when the storefront you embed layers a customer’s own public rows over a
common base catalog and you want both in one field.
- Read-only. The write axis on a target field is
own/noneregardless — a write still stampstenant = Band can never land in the shared baseline.target_or_nullwidens reads only. - Same confinement, wider tenant predicate. The public-subset filter still applies to
both arms; only the tenant equality is relaxed to
(= B OR IS NULL). TenantAis never reachable. - The public subset is mandatory here even under a capability. For plain
target, avia: [capability]field is exempt from the subset (the audience-bound capability namingtid = Bis the authorization).target_or_nullremoves that exemption: the sharedNULL-base rows are a different trust partition than the capability-authorizedB, so the base arm must be visibility-gated. Atarget_or_nullfield over a table with no declared public subset is refused deny-by-default. - Plain-Column tables only. Like
own_or_null(Dimension 2), the OR-null widening applies only to a straight tenant-column table — never a session-keyed or unscoped table (noNULL-row to safely share). The SQL/GDC subgraph path (AND-only terms) fails closed rather than approximate the disjunction.
# B's published products layered over the shared base catalog
baseProducts: [Product!]! @tenant(scope: target_or_null, via: [domain], public: "products")
Worked example — a multi-storefront SaaS
One deployment serves every customer on their own domain, all rows in one database
discriminated by tenant_id:
-
Attach the domains and tag each with its tenant in site config (
domains.contexts):{ "domains": { "wildcards": ["*.shops.example.com"], "contexts": { "acme.shops.example.com": "acme", "globex.shops.example.com": "globex" } } } -
Declare
domain-sourced tenancy as the site ceiling (handlers.tenancy):{ "mode": "scoped", "column": "tenant_id", "sources": [ { "kind": "domain" } ], "read": "own", "write": "own" } -
Write ordinary handlers. A request to
acme.shops.example.comruns with the tenant bound toacme; everyormquery and every{scope}-marked raw query sees onlytenant_id = 'acme'. The handler code contains no tenant logic at all — add a customer by attaching a domain and tagging it, no redeploy.
For a token-authenticated console over the same data, declare a second function with
sources: [ { "kind": "token", "claim": "tid" } ] + a token_claims block, and (if it needs
an admin view across tenants) read: all — which the operator must enable fleet-wide with
allow_cross_tenant_db.
Migrating from pre-0.4
Before v0.4.0 a guest scoped its own queries:
#![allow(unused)]
fn main() {
// pre-0.4 — REMOVED
let db = sql::open("main")?.scoped("tenant_id", tenant)?;
let db = orm::open("main")?.scoped("tenant_id", tenant);
}
That guest-supplied scope is gone. Now:
- Drop
.scoped(col, value)—open()returns a plain handle; query entry is on it directly. - Declare tenancy in config (Dimensions 0–2 above) so the host injects the scope.
- For raw SQL, add the
{scope}marker where your oldtenant_id = ?predicate was. Theormbuilder needs no change beyond dropping.scoped(...)— it scopes structurally.
The value the host binds is the verified one (token claim / domain tag), so the app no
longer computes or trusts a tenant variable at all. A single-tenant app that had no
.scoped(...) call declares { "mode": "disabled" } (or runs under single-tenant/dev
where undeclared is fine).
See also
- Use kv / sql / blobstore / messaging — the
sqlandormbindings the scope is applied to. - SiteConfig schema — the
handlers.tenancyfield. - Attach a custom domain —
domains.contextsfor the domain source. - boatramp.cfg schema — the
require_tenancy_declarationandallow_cross_tenant_dbposture knobs.
Run owner-gated schema migrations
Your handlers own their rows; you also need to evolve the schema they run against — add a table, an index, an RLS policy, a column — and sometimes run a real data migration (backfill a column, sync an external source, verify an invariant). boatramp gives you an owner-gated, control-plane migration surface for its managed databases whose base primitive is a wasm function: you supply the ordered steps, boatramp owns the sequencing, tracking, idempotency, and transactionality, and you trigger it with an admin token. There is no migration framework to embed and no long-lived DDL credential to hand out — a step runs once, in order, and is recorded so a re-run is a no-op.
Postgres only (this release). The owner-role / RLS model and transactional DDL are Postgres semantics. A migration against a MySQL managed database is refused with a clear error. Shipped in v0.4.25.
The step model
A migration is an ordered set of steps. Each step has a stable, author-given id and
is exactly one of three kinds:
- a
functionstep — the base: a project function that boatramp invokes to do arbitrary migration work, including DDL via a host-mediated owner-role capability; - a
sqlstep — sugar: a DDL/DML script run as the project owner role; - an
extensionstep — sugar: an allowlisted Postgres extension enabled by name.
sql and extension are the easy cases of the same ordered, ledgered sequence — a sql
step is just “run this DDL as the owner”, with no function to author. Reach for a
function step when a migration needs logic: a conditional backfill, a verification query,
an external-source sync, or DDL interleaved with DML.
boatramp owns everything else. It applies only the pending suffix (steps whose id
isn’t already recorded), in the order you gave, exactly once, and it refuses a set that
reordered or changed an already-applied step (see How it stays safe).
A function migration step
A function step is an ordinary boatramp project function — a wasi:http handler — that
imports boatramp:handlers/migrate-ddl. Inside a migration run the host attaches that
capability, backed by an orchestrator-owned owner-role connection; the function calls
migrate::exec / migrate::exec-batch (DDL/DML) and migrate::query (verification) and
the host executes each statement as the owner. The function can also do everything a normal
function can — sync an external source over wasi:http, compute, log — so a data migration
and its schema change live in one author-controlled step.
A minimal Rust guest (from examples/handlers/migrate-fn):
#![allow(unused)]
fn main() {
wit_bindgen::generate!({ world: "handler", path: "wit", generate_all });
use boatramp::handlers::migrate_ddl;
use boatramp::handlers::migrate_ddl_types::MigrateError;
use exports::wasi::http::incoming_handler::Guest;
// … wasi:http request/response glue elided …
struct Component;
impl Guest for Component {
fn handle(request: IncomingRequest, outparam: ResponseOutparam) {
// Create the schema (auto-committed as the owner role).
if let Err(e) = migrate_ddl::exec(
"CREATE TABLE IF NOT EXISTS orders (\
id bigserial PRIMARY KEY, tenant_id text NOT NULL, total numeric NOT NULL)",
) {
return respond(outparam, 500, format!("ddl failed: {}", reason(&e)).as_bytes());
}
// Back-fill / verify: the owner sees ALL rows (correct for a data migration).
match migrate_ddl::query("SELECT count(*) FROM orders WHERE tenant_id IS NULL") {
Ok(json) => respond(outparam, 200, json.as_bytes()), // a 2xx return records the step
Err(e) => respond(outparam, 500, format!("verify failed: {}", reason(&e)).as_bytes()),
}
}
}
fn reason(e: &MigrateError) -> String {
match e {
MigrateError::NotAMigration => "not-a-migration".into(),
MigrateError::LedgerProtected => "ledger-protected".into(),
MigrateError::TxnControl => "txn-control".into(),
MigrateError::Sql(m) => format!("sql:{m}"),
}
}
}
The
compat::migratemodule in theboatramp-uchron-shim(its off-by-defaultmigratefeature) wraps this so a migration function callsmigrate::exec(ddl)/migrate::query(sql)ergonomically.
Four properties of a function step are load-bearing — know them before you write one:
- The capability is inert outside a migration run.
migrate-ddlis attached only when theProject·Adminorchestrator invokes the function as a migration step (a host-stamped context). Invoked as a normal request, consumer, or cron job, the same component has no binding and every verb returns a distinct, self-explainingnot-a-migrationerror (not a bareaccess-denied) — so the author can tell “this is running where owner-DDL isn’t available” from “my DDL was wrong”. The function does not self-flag as a migration; the bundle references it, and the orchestrator sets the context. - The binding-split. During a migration run the function does not get its normal
tenant-scoped
sqlbinding. All of its database work goes throughmigrate-ddlat owner altitude, so it reads and writes all rows regardless of tenant — which is exactly right for a schema or data migration, and is why a migration function is not RLS- subject. (It cannot hold both a tenantsqlconnection and the owner connection in one run, so it can never disable RLS as owner and then read cross-tenant through a tenant binding.) - No ledger, no transaction control.
migrate-ddlrefuses any statement that references the host-ownedboatramp_migrationsschema (ledger-protected) or issues its ownBEGIN/COMMIT/ROLLBACK(txn-control) — eachexecauto-commits on the host-held owner connection. Your step can neither corrupt the ledger it is tracked in nor desync the wrapper. - At-least-once + author-idempotent. A function step’s ledger row is written only
after the function returns success (a 2xx). A crash or a non-2xx return mid-step records
nothing, so the whole step re-runs on the next apply. Write the migration logic so a
re-run is safe (
CREATE TABLE IF NOT EXISTS, an idempotent back-fill). A function that returns non-2xx surfaces as afailed { id, error }in the report — its own status + body for an author-returned error, orfunction invocation failedfor a trap.
Pin the function version
By default a function step resolves the function’s active version at apply time. Pin an
explicit version in the step so a replay runs identical bytes:
{ "id": "0004_backfill", "function": { "name": "backfill-orders", "version": "v3" } }
The recorded content-hash for a function step binds the resolved component blob, not
just the version tag — so if you redeploy backfill-orders under the same v3 tag after it
was applied, the next apply catches the change as a content-hash mismatch (409). Pin the
version you tested, and treat an applied migration’s bytes as immutable.
The bundle: upload, then trigger
Input is upload-then-trigger. You serialize the ordered step set as a JSON bundle, upload it content-addressed via the existing blob endpoint, then reference it by hash when you apply. A hand-authored bundle and a CLI-generated one serialize identically (stable JSON field set), so they hash-agree.
The bundle body is a JSON object with a steps array. Each step is an id plus exactly one
of function / sql / extension:
{
"steps": [
{ "id": "0001_init",
"sql": "CREATE TABLE orders (id bigserial PRIMARY KEY, tenant_id text NOT NULL, total numeric NOT NULL);" },
{ "id": "0002_pgcrypto", "extension": "pgcrypto" },
{ "id": "0003_orders_tenant_idx",
"sql": "CREATE INDEX CONCURRENTLY orders_tenant_idx ON orders (tenant_id);",
"no_transaction": true },
{ "id": "0004_backfill",
"function": { "name": "backfill-orders", "version": "v3", "args": "{\"batch\":500}" } }
]
}
- A
sqlstep carries the script; add"no_transaction": truefor DDL Postgres forbids in a transaction (see below). - An
extensionstep names the extension (allowlisted; see below). - A
functionstep names the project function, an optional pinnedversion(defaults to the active version), and an opaqueargsstring handed to the function as its invoke request body — boatramp does not interpretargs; the function parses it.
The boatramp CLI does the upload-then-trigger in one step. You give it the step set one of two
ways — a migrations directory it assembles (the everyday path), or a pre-authored bundle file.
From a migrations directory (--dir). Keep one file per step in a directory; the CLI reads
them, assembles the canonical bundle, uploads, and triggers:
migrations/
0001_init.sql # → sql step (file body is the script)
0002_pgcrypto.ext # → extension step (file body is the extension name)
0003_orders_idx.notx.sql # → sql step with no_transaction (CREATE INDEX CONCURRENTLY)
0004_backfill.fn.json # → function step: {"name":"backfill-orders","version":"v3","args":"…"}
boatramp project migrate apply --project acme --db appdb -d migrations/
The step id is the file name minus its kind suffix, and steps apply in lexicographic filename
order (zero-pad your prefixes). The suffix picks the kind — .sql, .notx.sql, .ext,
.fn.json — so nothing is silently miscategorized; a file with any other suffix is a hard error
(a mistyped migration must not vanish), and two files resolving to the same id are refused. An
extension must be an .ext file because a raw sql step may not CREATE EXTENSION (below).
From a pre-authored bundle (--file). If you generate the canonical { "steps": [ … ] }
document yourself, upload it directly instead:
boatramp project migrate apply --project acme --db appdb -f migrations.json
Either way the CLI PUTs the bundle to the blob endpoint, POSTs the trigger, and renders the
MigrationReport (a step that ran-but-failed exits non-zero, so a deploy script halts on it);
--json emits the raw report. The verb lives under project (not the top-level boatramp migrate, which is the unrelated pre-0.2.0 store re-key) because a schema migration is a
project-scoped admin operation. A directory-assembled bundle and a hand-authored one with the same
steps serialize to the same canonical form, so they hash-agree.
Equivalently, the raw HTTP contract the CLI drives — upload the bundle (its hash is the sha256-hex; the endpoint verifies it), then trigger:
# 1. upload the content-addressed bundle
HASH=$(sha256sum migrations.json | cut -d' ' -f1)
curl -sS -X PUT "https://cp.example.com/api/blobs/$HASH" \
-H "Authorization: Bearer $BOATRAMP_TOKEN" \
--data-binary @migrations.json
# 2. apply it against managed database `appdb` in project `acme`
curl -sS -X POST https://cp.example.com/api/projects/acme/migrate/appdb/apply \
-H "Authorization: Bearer $BOATRAMP_TOKEN" \
-H 'content-type: application/json' \
-d "{ \"bundle\": \"$HASH\" }"
Paths target the named project; the top-level /api/migrate/… counterpart targets the
default project (with the CLI, omit --project). The migrate client is a thin uploader —
it assembles the bundle from whatever on-disk layout you keep, PUTs the blob, and POSTs the
trigger. boatramp stays agnostic to your directory shape; the HTTP surface above is the contract.
apply / dry-run / baseline / status
Four verbs on a managed database :db, all referencing an uploaded bundle by hash (except
status, which reads the ledger):
Apply the pending suffix ({ "bundle": "<hash>" }). The response is a structured
MigrationReport:
{ "newly_applied": ["0002_pgcrypto", "0003_orders_tenant_idx", "0004_backfill"],
"already_applied": ["0001_init"],
"pending": [],
"failed": null,
"kinds": { "0001_init": "sql", "0002_pgcrypto": "extension",
"0003_orders_tenant_idx": "sql", "0004_backfill": "function" } }
already_applied— recorded on an earlier call, skipped (idempotent no-op).newly_applied— applied by this call, in order.pending— populated only by a dry-run (the ids that would apply); empty on a real apply.failed— the step that ran but failed ({ id, error });nullon success.kinds— every reported id mapped to its kind (sql/extension/function), so a thin client can tell what each id was without re-parsing the bundle.
Dry-run computes the plan without running or recording anything — same body,
pending lists the ids that would apply:
boatramp project migrate dry-run --project acme --db appdb -d migrations/
# raw HTTP:
curl -sS -X POST .../migrate/appdb/dry-run -H "Authorization: Bearer $BOATRAMP_TOKEN" \
-H 'content-type: application/json' -d "{ \"bundle\": \"$HASH\" }"
Status reads the applied ledger (id, ordinal, content hash, kind, applied-at, and the
origin marker — apply vs baseline):
boatramp project migrate status --project acme --db appdb # add --json for the raw ledger
# raw HTTP:
curl -sS .../migrate/appdb/status -H "Authorization: Bearer $BOATRAMP_TOKEN"
Baseline is covered in its own section below.
Tokens: admin to mutate, read to inspect
apply, dry-run, and baseline require a Project·Admin token; status needs only
Project·Read. This is deliberately stronger than the deploy-grade publisher right
that ships code — DDL redraws the schema, so a ship-only CI token cannot migrate the
schema. Mint a scoped admin token per project rather than reusing the fleet root
(see Make a scoped deploy token). Uploading the bundle blob is a
deploy-grade action (Blobs·Deploy), the same as any other blob upload.
HTTP status codes: 200/422 vs the error codes
boatramp splits “couldn’t attempt the request” from “a step ran but failed”:
-
A clean apply is
200; a step that ran but failed (asqlerror, or a function returning non-2xx) is422with the sameMigrationReportbody, wherefailednames exactly which migration broke and why:{ "newly_applied": ["0001_init"], "already_applied": [], "pending": [], "failed": { "id": "0002_bad", "error": "relation \"orders\" does not exist" }, "kinds": { "0001_init": "sql", "0002_bad": "sql" } }Either way the body is the same shape, so a thin client parses
failed{id,error}and branches on the status. -
Everything else is a non-2xx without a report body:
409(the set diverged from the ledger, or an applied step’s body/blob changed),503+Retry-After(the managed database is still starting — retryable),501(no managed database on this node),400(a malformed step — none or more than one offunction/sql/extension, an unparsable bundle, a rawCREATE EXTENSIONin asqlstep, or an extension not on the allowlist).
How it stays safe
The migration surface runs owner-authority DDL — from a sql step or a function’s
migrate-ddl calls — without ever handing out cluster-superuser reach.
DDL runs as a dedicated per-project non-superuser owner role. Every managed tenant is provisioned with a three-identity model:
- the cluster superuser — only ever provisions the shells (creates the database and the roles); a migration never connects as it;
- a per-project owner role, created explicitly
NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS— this is who both yoursqlsteps and a function step’smigrate-ddlcalls run as; - the runtime login role your handlers connect as, kept a non-owner so row-level security is still enforced against it.
Because the owner role is a plain non-superuser that owns only this tenant’s own database,
Postgres itself denies the dangerous moves — DROP DATABASE other,
ALTER ROLE … SUPERUSER, COPY … TO PROGRAM, an untrusted CREATE EXTENSION — by
privilege, not by a check boatramp has to remember. A function step’s DDL is therefore no
more powerful than a sql sugar-step (both are arbitrary DDL as the confined owner); a
function adds interleaving with its own logic, not a new escalation.
On a Shared multi-tenant server, a real project runs its migrations as that project’s own per-project non-superuser owner role, so the engine denies cross-tenant / cross-database reach by privilege. On a single-tenant install, the reserved
defaultproject’s migrations run as that install’s configured identity (your own user) — there is no other tenant to be isolated from.
The ledger is host-owned. Applied migrations are tracked in
boatramp_migrations.schema_migrations — a schema outside public that the owner role
owns and the runtime app role has no grant on (the app only ever gets DML on public). A
function step is additionally forbidden from touching that schema through migrate-ddl
(ledger-protected). Your handlers cannot read, forge, or clear their own migration history.
Ordering is fixed and immutable. On every apply boatramp checks the recorded ledger is an
ordered prefix of the steps you sent, matching id and a content hash — for a
sql/extension step the intrinsic hash of its body, and for a function step the hash
bound to the resolved component blob:
- a reordered or dropped step ⇒
409(prefix divergence), and - editing an already-applied step’s body, or redeploying a pinned function under the
same version tag, ⇒
409(content-hash mismatch).
So you cannot silently re-apply, reorder, or rewrite history — the only legal change to a migration set is appending new steps.
Enable an extension
A raw sql step may not CREATE EXTENSION (a 400, or refused fail-closed by the
non-superuser owner regardless) — and neither can a function’s migrate::exec. The sole path
is a dedicated extension step:
{ "id": "0002_pgcrypto", "extension": "pgcrypto" }
boatramp runs a host-templated CREATE EXTENSION IF NOT EXISTS "<name>" — no guest SQL, no
injection surface — but only if <name> is on the operator’s trusted-extension allowlist,
set in the node config:
[handlers.bindings.sql]
migrate_trusted_extensions = ["pgcrypto", "uuid-ossp", "citext"]
An empty/unset list means no extension can be enabled through a migration (the safest
default); a name not on the list is refused with a 400. This allowlist is the single,
operator-controlled gate on which extensions a project may install — deliberately the
operator’s decision, not the app author’s, because some extensions (dblink,
postgres_fdw, …) widen cross-database reach. Keep it tight and add those only knowingly.
Non-transactional sql steps
boatramp wraps each sql step and its ledger row in one owner transaction so DDL and its
record commit (or roll back) together. Some Postgres DDL cannot run inside a transaction —
CREATE INDEX CONCURRENTLY, ALTER TYPE … ADD VALUE, VACUUM. Mark those
no_transaction: true:
{ "id": "0003_orders_tenant_idx",
"sql": "CREATE INDEX CONCURRENTLY orders_tenant_idx ON orders (tenant_id);",
"no_transaction": true }
The trade-off is explicit: boatramp runs the script, then records the ledger row in a
following statement, so there’s a window where the DDL applied but the row didn’t (a crash
between them). The author owns that step’s idempotency — write it so a re-run is safe
(CREATE INDEX CONCURRENTLY IF NOT EXISTS), because a re-submit will run it again.
Transactional sql steps have no such window; reach for no_transaction only for DDL
Postgres forbids in a transaction. (A function step is always at-least-once — see
A function migration step — so the same idempotency discipline
applies to any function.)
Adopt an existing database: baseline
A database provisioned by a pre-v0.4.25 boatramp already carries its full schema, applied
via the old path, but the host-owned ledger starts empty — so a first apply of your full
step set would treat every step as pending and try to re-run CREATE TABLE … against a
populated database (a 422). baseline closes that gap: it records a prefix of the step set
as already-applied WITHOUT running any step.
boatramp project migrate baseline --project acme --db appdb \
-d migrations/ --up-to 0097_last_old_path_migration
# raw HTTP:
curl -sS -X POST .../migrate/appdb/baseline -H "Authorization: Bearer $BOATRAMP_TOKEN" \
-H 'content-type: application/json' \
-d "{ \"bundle\": \"$HASH\", \"up_to\": \"0097_last_old_path_migration\" }"
- It writes ledger rows for the steps up to and including
up_to(or the whole set ifup_tois unset), computing the same content-hashapplywould — but runs neither asql/extensionstep nor a function invocation. Recorded rows carry anorigin = baselinemarker, sostatusdistinguishes a baselined prefix (never run on this DB) from a genuinely applied one. - A later
applyof the same set sees the baselined prefix asalready_applied(matching ids- hashes) and runs only the genuinely-pending suffix.
Project·Admin, audited, and prefix-consistent: valid only on an empty ledger or as a strict, consistent extension of what’s recorded — a divergence (a boundary behind the recorded rows, or anup_tonot in the set) is refused409, exactly likeapply.
So you baseline preview/prod at “everything applied through the last old-path migration”, then
a normal apply lands only the held suffix — no data migration, no re-creating an existing
object.
Operators: migrating existing tenants to the owner model
New tenants provision with the three-identity model automatically — nothing to do. A tenant provisioned by a pre-v0.4.25 boatramp, though, has its database owned by the runtime role and its tables owned by the cluster superuser; there is no owner role yet, so a migration has nowhere to connect as owner. Move such a tenant once, as a one-shot step.
Find the boatramp-derived owner role name — it’s the _owner-suffixed per-tenant role, in
pg_roles (it will exist after you re-run provisioning; the name is derived from the managed
binding + tenant). Then, as the superuser, against that tenant’s database:
ALTER DATABASE "<db>" OWNER TO "<owner_role>";
REASSIGN OWNED BY <superuser> TO "<owner_role>";
Then re-run provisioning (it is idempotent) so the owner-keyed default privileges and the
sealed owner credential are in place. After that, the migration surface can connect as the
owner role and the tenant is on the modern model. This is the operator’s one-shot step; tenants
created afterward need none of it. (This fixes who runs DDL; to adopt the migrator on a DB
that already has its schema, baseline the recorded prefix as above.)
A note on trust
A migration function is a trusted, Project·Admin-authored data-plane actor, by design:
- It reads and writes all rows as the owner role (RLS does not apply to it) — correct for a schema/data migration, but it means a migration function is not confined the way a normal tenant-scoped handler is. Only deploy functions you’d trust with your whole schema as migration steps.
- It retains
wasi:httpegress and its other granted capabilities; a migration invocation inherits the same egress / SSRF posture as any function on the node. An external-source sync from a migration is subject to the same egress controls (and no more) as an ordinary handler.
Neither is a hole in the owner-role confinement — Postgres still denies the owner cross-database / role-escalation moves — but they are the honest cost of “a migration is a function”: you are running author-supplied code at owner altitude, under an admin token, on demand.
The general pattern
This surface is an instance of a general boatramp shape: an owner-authenticated control-plane operation with a thin client — the caller assembles a declarative request (here, a content-addressed step-set bundle) and posts it with an admin token, while boatramp owns the privileged, sequenced, tracked execution. There is no long-lived privileged credential in the client and no imperative script running against your database. Expect the same “admin request → owner-only admin API” shape to generalize to future privileged operations.
See also
- Isolate tenants within a project — the RLS the runtime role is held to, which the owner/runtime split preserves (and which a migration function operates above).
- Use kv / sql / blobstore / messaging — the managed
sqlbinding a migration targets (and which a function step does not hold during a migration run). - Bootstrap authentication & mint tokens / Make a scoped deploy token — the admin token these endpoints need.
- Control-plane HTTP API — the full
/api/migrate/*and/api/blobs/*endpoint reference.
Serve a GraphQL API
A GraphQL API on boatramp is a normal Wasm handler that speaks GraphQL (for
example built with async-graphql). Point boatramp at it, turn on
[handlers.graphql], and the platform treats GraphQL as a protocol it
understands — guarding it, resolving persisted queries, and (optionally)
federating several subgraphs into one supergraph. boatramp stays GraphQL-aware,
not a GraphQL engine: it parses a query only as far as the guard, persisted
queries, and the federation planner need; your schema and resolver logic stay in
your handler.
Everything below is opt-in per site and off by default.
What you can turn on
GraphQL support is a set of independent, composable features — reach for the ones you need:
| Feature | What it does |
|---|---|
| Query-guard | Reject over-deep/complex or introspection queries at the edge. |
| Persisted queries + safelist | Send a query hash instead of the full query; or lock serving to a pre-registered allowlist. |
| GraphiQL explorer | Serve the in-browser IDE to a browser GET. |
| Data connector | Serve a GraphQL API generated from a managed database — no resolver code. |
| Subscriptions | Serve a subscription as a graphql-sse event stream off a messaging topic. |
| Federation | Compose several subgraph handlers into one supergraph gateway. |
| Guest supergraph runs | Let a handler run a supergraph operation in-process. |
| Cookie session auth | Authenticate a browser app from an HttpOnly session cookie. |
Turn it on
// boatramp.cfg — the site's handler config
handlers: (
enabled: true,
graphql: (
enabled: true,
max_depth: Some(12),
max_complexity: Some(500),
introspection: Some(false),
),
)
The GraphiQL explorer
graphql: ( enabled: true, graphiql: true, introspection: Some(true) )
With graphiql on, opening the endpoint in a browser (any Accept: text/html
request) serves the GraphiQL IDE, which posts queries back to the same URL. Pair it with
introspection: Some(true) so the explorer can load your schema. It’s a developer
convenience — leave it (and introspection) off in production.
GraphQL from your database (no resolver code)
boatramp already runs your site’s database as a managed workload, so it can also expose it as a GraphQL API directly — no handler, no resolvers. Turn on the data connector and name what to expose:
graphql: (
enabled: true,
data: (
enabled: true,
tables: {
"users": (
columns: ["id", "name"],
// Row-level isolation: only rows whose `tenant` equals the request's
// host-asserted `project` claim are visible.
row_filter: [( column: "tenant", claim: "project" )],
),
},
),
)
boatramp introspects the database, generates the schema (an object type per table plus
users, users_by_pk, and where/order_by/limit/offset arguments), and answers
each query by compiling it to one parameterized SQL statement. It is a compiler, not
an execution engine: a query it can’t lower is rejected, never run partially — the database
does the executing.
Two guarantees make this safe to point at real data:
- Deny-by-default. Only the tables and columns you list are exposed; everything else is invisible and unqueryable. Selecting an unexposed column is an error, not a leak.
- Fail-closed row isolation. A table’s
row_filteris applied to every access, its value bound from a verified claim (e.g.project). If the claim is absent the request is denied — a missing claim never widens access.
Every value is a bound parameter (injection-safe), and every identifier comes only from the introspected, exposed schema. It’s off by default; managed libsql is supported today.
Multi-tenant SaaS: isolate by a claim from your own app token
By default a row_filter binds the host-asserted project claim — good for a project-per-tenant
model. For a SaaS that keeps many tenants as rows inside one project (the Shopify shape),
isolate by a claim from your app’s own bearer token instead. Point the connector at your
IdP’s issuer + JWKS, and bind the tenant claim your tokens carry:
data: (
enabled: true,
// Verify the caller's `Authorization: Bearer` against your IdP; its claims become
// bindable. Map the app's public JWKS into a host env var (like a secret), or give a URL.
claims_from_token: ( issuer: "https://console.acme.com", jwks_env: "APP_JWKS" ),
tables: {
"portfolio_item": (
columns: ["id", "title", "tenant_id"],
row_filter: [( column: "tenant_id", claim: "tid" )], // `tid` from the verified token
),
},
)
The claim value is used only from a fully verified token — signature (RSA / EC / Ed25519,
pinned to the JWKS key, never the token’s alg), iss, exp/nbf, and a resolvable kid.
A missing, expired, forged, or wrong-issuer token contributes no claim, so the filter denies
(never “all rows”). The host-asserted project still applies and a token can never override
it, so app-token isolation nests inside the project boundary. The same claim isolates at
every depth (through relationships) and on every write (an insert is forced to carry the
tenant), so there is nothing to miss. This is exactly what makes it safe to expose one shared
database to many tenants with no resolver code.
Browser session auth (httpOnly cookie)
A browser SPA can authenticate with the app token entirely out of JavaScript — the token
stays in an HttpOnly cookie the JS can’t read (XSS-safe). Opt the site in with cookie_auth
and boatramp treats the named cookie as the bearer wherever the bearer already flows, including
the data connector’s claims_from_token check above: a verified cookie isolates a SaaS tenant
by its token claim exactly as a header bearer would, with no token ever exposed to JavaScript.
This is a general handler auth method, not GraphQL-specific — see
Authenticate a browser with a session cookie
for the config, the cookie attributes it requires (HttpOnly; Secure; SameSite=Lax; __Host-),
and the CSRF model.
Relationships. Foreign keys become relationship fields — a to-one field for each outgoing FK and a to-many field for the rows that reference this one. A nested query resolves in one SQL statement (relationships compile to correlated JSON subqueries), so there is no N+1, and the row filter applies inside each relationship too — a nested row a tenant shouldn’t see stays hidden.
Mutations are opt-in:
graphql: ( enabled: true, data: ( enabled: true, mutations: true, tables: { … } ) )
You get insert_<table>, update_<table>, and delete_<table>, each returning
{ affected_rows }. Writes run in a transaction, use only exposed columns, and the row
filter is enforced on every write: an inserted row is forced to belong to the tenant, and an
update/delete only touches the tenant’s rows. An unbounded update/delete (no where) is
refused.
A wasm-resolved field. A field can be served by a wasm function instead of a column, listed per table:
"users": ( columns: ["id", "name"], resolvers: { "recommendations": "recommender" } )
The connector resolves the row’s columns from SQL, then fills the delegated field with a
single batched invoke to the function (a local _entities fetch, joined by key — no N+1).
The map is also the allowlist: only these fields delegate, only to these functions. This is
GraphQL→SQL and GraphQL→Wasi blended at field grain; the coarser form is a SQL source acting
as a federation subgraph composed with wasm subgraphs (see Federation).
The query-guard
With the guard on, an incoming GraphQL operation is parsed at the edge and rejected before your handler runs when it:
- exceeds
max_depth(deepest selection nesting, with fragments expanded so a query can’t hide depth behind a fragment), or - exceeds
max_complexity(total field count — a schema-free cost proxy), or - is a schema-introspection query (
__schema/__type) andintrospectionis not allowed.
This is defense-in-depth over the per-handler fuel cap against the deep/wide
query denial-of-service class the fuel cap can’t fully catch. A rejection is a
GraphQL-shaped 400.
Every query-bearing POST is inspected — the body is buffered up to a 1 MiB edge
cap regardless of its declared length, so a chunked or oversized request can’t
slip past the guard by omitting or misstating Content-Length. A GraphQL request
is small; a query body over the cap is refused with a GraphQL-shaped 413 rather
than passed through. Only an upload/form POST (multipart/form-data,
application/x-www-form-urlencoded), which carries no query the edge parses, passes
through untouched.
Persisted queries + safelist
graphql: ( enabled: true, persisted_queries: true ) // or: enforce_safelist: true
With persisted_queries, a client may send a query hash
(extensions.persistedQuery.sha256Hash) instead of the full query. The edge
resolves the hash to the stored query and hands the full query to your handler.
On a first miss it returns PersistedQueryNotFound; the client re-sends the
query alongside the hash and the edge registers it (after verifying the hash).
enforce_safelist mode is stronger: only pre-registered hashes run and the
edge never registers a new one — persisted queries become a query allowlist, a
real security control. (This switch was named safelist before v0.6.0.)
The safelist (managing the allowlist)
Curate the project’s trusted operations with boatramp graphql safelist.
Registering an operation returns its hash (validated for parse + depth/complexity
first, so a bad operation is rejected here, not at run time):
# Register a trusted operation (returns its hash). `--project` selects the project;
# give the query inline or with `--file query.graphql`.
boatramp graphql safelist add '{ me { name } }' --project acme
boatramp graphql safelist list --project acme # list registered operations
boatramp graphql safelist rm <hash> --project acme # remove one by hash
You can also declare the trusted operations in the manifest and register them
at apply time — inline via safelisted_ops, or from a file via
safelisted_ops_path (read client-side, resolved relative to the manifest dir; the
two are mutually exclusive). This is register-only (union): applying only ever
ADDS operations — it never prunes — so removal stays the explicit safelist rm.
graphql: (
enabled: true,
enforce_safelist: true,
safelisted_ops: [ "{ me { name } }", "query Feed { posts { id title } }" ],
// or, from a file (a JSON array of operation strings, or a single-operation file):
// safelisted_ops_path: "graphql/persisted-ops.json",
)
Run the supergraph from a guest
A function may run a GraphQL operation against the project’s composed supergraph
in-process (cross-subgraph planning, no network hop) through the host’s graphql
capability — a WIT interface a guest imports, exposing run(query, variables) (the full
operation) and run-persisted(hash, variables) (by safelist hash). It is how a guest
operates the unified API without a network round-trip. Guest runs are deny-by-default:
only safelisted operations run (register them above — run hashes the supplied query and
checks the same allowlist), the function forwards its own bearer (re-verified by each
subgraph — no escalation), and the run dispatches at the guest’s own call depth against the
shared in-process cap (so a run → subgraph-fetch → run chain can’t loop). Grant it by
importing graphql (and allowing it in the site’s allow_imports).
Subscriptions
A GraphQL subscription operation sent to a graphql-enabled site is served as a
graphql-sse event stream (the “distinct connections” mode). The subscription’s
single root field names a messaging topic; a producer — a mutation handler, a
function, a consumer — publishes each event to that topic (via the messaging
binding), and each is delivered to the client as a graphql-sse next event, with
Last-Event-ID resume and a heartbeat, bounded by the site’s stream connection caps.
subscription { messageAdded { id body } } # streams the "messageAdded" topic
Because the frames are standard graphql-sse, a normal GraphQL client (Apollo Client,
urql, or the graphql-sse library) consumes the subscription directly.
The host only fans out — it does not execute the subscription. The payload your
producer publishes to the topic is delivered verbatim as the next event’s data, so
publish the execution result for each event — the JSON your resolver would return,
e.g. {"data": {"messageAdded": {"id": "1", "body": "hi"}}}. No handler component
runs per event.
Federation
For a multi-team schema, run several subgraph handlers and let boatramp compose them into one supergraph.
Register subgraphs
Publish each subgraph’s SDL to the project registry with
boatramp graphql subgraph put:
boatramp graphql subgraph put accounts --sdl accounts.graphql --project acme
boatramp graphql subgraph put reviews --sdl reviews.graphql --project acme
Each publish recomposes the whole supergraph and rejects the change if it
does not compose (a field co-owned without @shareable, or SDL that does not
parse) — a bad publish never corrupts the registry. Read the composed
supergraph with boatramp graphql supergraph --project acme.
A function subgraph registers itself — no hand-written SDL, and often no separate call at all:
-
Zero-touch. A component that self-declares a subgraph — a
"subgraph": trueentry in itsboatramp:function-manifestcustom section (any guest toolchain that emits the marker qualifies) — is auto-registered on deploy: boatramp reads the marker from the uploaded component, introspects the pending version’s_service { sdl }, and publishes it. Justboatramp deploy(orPUT /api/functions/accounts) — the subgraph joins the supergraph automatically. -
Explicit. For a hand-written subgraph (or to register out-of-band), call it directly — boatramp introspects the deployed function and publishes:
boatramp graphql subgraph function accounts --project acme
boatramp invokes the function anonymously (the SDL is public), publishes the returned SDL
(recomposed + validated like any subgraph), and records it as a function backend. If the
function is not deployed yet the explicit call is a 409; if it does not answer
{ _service { sdl } } (not a federation subgraph) it is a 422; a schema that does not
compose is a 400.
Once a function is a registered subgraph, each later redeploy refreshes its registered
SDL automatically — boatramp introspects the pending version before it goes live and
refuses the deploy (400) if the new schema no longer composes with the rest of the
supergraph, so the composed graph is never left stale or broken. An ordinary, non-subgraph
function deploy is unaffected (no marker, no registry entry → no-op).
For a coordinated migration across several subgraphs (an entity-key change, moving a
field’s ownership) where an intermediate step can’t compose, use the escape hatches:
deploy the new version without touching the registry with
PUT /api/functions/accounts?register_subgraph=false, or unregister the subgraph first
with boatramp graphql subgraph rm accounts --project acme, then re-register when the set
composes again.
A subgraph can also be SQL-backed — the data connector
acting as a federation subgraph. Register it by naming a site’s managed database and
what to expose; boatramp introspects the database and generates the @key SDL for you
(no hand-written SDL):
# accounts-sql.json:
# {"site": "accounts",
# "config": {"enabled": true, "tables": {"users": {"columns": ["id", "name"]}}}}
boatramp graphql subgraph sql accounts --file accounts-sql.json --project acme
The gateway then resolves that subgraph’s fetches by compiling to SQL — both its root
fields and its _entities fetches (a keyed SELECT), so a SQL source is a full
federation citizen, composable with wasm subgraphs.
The gateway
Mark a site as the gateway:
graphql: ( enabled: true, federated: true )
A query to that site is planned against the registered subgraphs — root
fields are grouped by their owning subgraph, and a field owned by another
subgraph on a @key entity becomes a dependent _entities fetch joined on the
entity key — and executed by dispatching each fetch to its subgraph
function over the in-process invoke path (no network hop), stitching the results
by key. A subgraph named accounts is invoked as the function named accounts.
The registry (the SDL) and the deployed subgraph function are separate: if you
register a subgraph’s SDL but never deploy a function of that name, a query that
routes to it fails with an explicit subgraph \accounts` is registered but no
function named `accounts` is deployed` error rather than a silently-wrong result.
Deploy each registered subgraph as a function of the same name.
The subgraph contract
A boatramp subgraph is just a function whose handler is a federation subgraph — it must expose the standard federation contract:
Query._service { sdl }returning its SDL, andQuery._entities(representations: [_Any!]!): [_Entity]!resolving entities by their@key.
You do not write these by hand: a GraphQL library with federation support
provides them. With async-graphql, derive your entity types and mark their
key with #[graphql(entity)] resolvers; the library generates _service and
_entities. boatramp’s gateway then speaks exactly this contract to your
subgraph — the schema semantics stay in your code.
Scope. Core federation —
@keyentities,@external/@shareable, root and entity fetches — is supported. The exotic Federation v2 corners (@interfaceObject, progressive@override, deep@requireschains) are not yet planned; a query that needs them will not compose or plan.
Cross-tenant target fields
A root Query/Mutation field can serve another tenant B’s public subset — a
storefront funnel, an embed, a share/handoff link — while every other field keeps serving
the caller’s own tenant. You declare it with the @tenant field directive; the host resolves
B and forces the confinement before your resolver runs, so the field can never reach
B’s private rows (or a third tenant). This is the GraphQL face of the target axis — see
Isolate tenants for the access-mode model it sits beside.
type Query {
# B's published products, resolved from the routed storefront domain
publicProducts: [Product!]!
@tenant(scope: target, via: [domain, handle], public: "storefront")
}
The directive’s arguments (parsed at composition — a malformed one refuses the publish):
| Argument | Values | Meaning |
|---|---|---|
scope | own (default) | target | target_or_null | own = the caller’s own tenant (today’s behavior). target = read B’s public subset. target_or_null = B’s public rows plus the shared NULL-tenant baseline (below). |
via | a non-empty list of domain | capability | handle | How the host resolves B per fetch, first-resolves-wins (below). |
public | a subset name (string) | Names the host-held visibility subset the read/write confines to. Mandatory for anonymous sources; a via: [capability]-only field is exempt (below). |
write | a list of column names (default empty) | The SET-allowlist for a target write; empty ⇒ read-only. handle may never appear in via of a write field (a public slug carries no write authorization). |
scope: target confines to exactly B. The host binds tenant = B AND <public subset>
onto the field’s sql/orm — one tenant, never all. A target write (non-empty write)
force-stamps tenant = B and the visibility columns, confines an UPDATE’s WHERE, and refuses
a DELETE or a raw-SQL write.
The via source model — how B is resolved per fetch:
domain— the terminating request domain’s context tag (a storefront served on the tenant’s own host). Anonymous, sopublicis mandatory.handle— a public slug from a third-party origin (embed / aggregator). Read-only, and admissible only on a subset the operator flaggedworld_public; the slug resolvesBonly from the operator’shandlesregistry (deny-by-default).publicis mandatory.capability— a host-verified, audience-bound capability token that namestid = Band the granted subset. The token is the authorization, so avia: [capability]-only field is exempt from the mandatorypublicsubset: the host confines totenant = Balone and the within-tenant per-client filter stays in your resolver (this is the v0.4.4 ruling). To mint one, see Mint a delegated capability.
The external /graphql gateway serves these on wasm subgraphs (since v0.4.7). The federation
planner splits fetches by tenancy class; the gateway resolves B per fetch from that fetch’s
own via/public/write and forces the tenant = B AND <public subset> confinement onto the
wasm subgraph’s own sql/orm — reads and writes, identical to a plain-wasm target route. B
and the confinement are always host-resolved, never guest input; the callee’s own declared tenancy
is bypassed (the composed SDL field’s class is the authority).
Include the shared baseline: scope: target_or_null
scope: target_or_null (v0.4.8) widens a target read to
(tenant = B OR tenant IS NULL) AND <public subset> — B’s public rows plus the shared
NULL-tenant baseline (a common base catalog, reference data, seeded rows every tenant shares).
Use it when a customer’s own public rows layer over a shared base.
baseProducts: [Product!]!
@tenant(scope: target_or_null, via: [domain], public: "products")
- Read-only. The write axis stays
own/none; a target write still stampstenant = Band never lands in the shared baseline. - The public subset is mandatory on BOTH arms even under a capability. Unlike plain
target, avia: [capability]field is not exempt here: theNULL-base rows are a different trust partition than the capability-authorizedB, so the base arm must be visibility-gated. Atarget_or_nullfield over a table with no declared public subset is refused deny-by-default. - Plain-Column tables only. The
OR NULLwidening applies only to a straight tenant-column table — never a session-keyed or unscoped table. The SQL/GDC subgraph path (AND-only terms) fails closed rather than approximate the disjunction.
Mint a delegated capability
Sometimes a guest wants to hand a scoped, time-bounded read of its own project’s public data to another party — an embed, a share link, an agent-to-agent handoff — or to narrow a single client’s access within a tenant. The usual reach for this is a bearer token, but minting a boatramp token from inside the sandbox would be a standing, over-broad credential, and it would force the guest to name a tenant it should never be able to name.
The capability capability replaces that. A guest attenuates a bounded slice of its own
authority into a fleet-signed (COSE) bearer it can give away: the token names a target
tenant B, a public subset, and an opaque app-context, and is redeemable only at the
minting project, over that project’s own data. The guest never sees the signing key and
never gets more authority than the project already holds — this is object-capability
delegation, not token minting.
It pairs with a target field: the party you hand
the token to redeems it as the via: [capability] source, and the host confines the read to
tenant B.
Mint it from a guest
Import boatramp:handlers/capability and call mint:
use boatramp:handlers/capability-minter.{mint};
use boatramp:handlers/capability-types.{mint-request};
#![allow(unused)]
fn main() {
let token = mint(&MintRequest {
target_tenant: "tenant_B".into(), // the app's tenant tag the capability grants
public_subset: "storefront".into(), // must match a redeeming route's `public`
app_context: vec![("sub".into(), "client-42".into())], // opaque; the host never reads it
ttl_seconds: 300, // clamped to the operator ceiling
})?;
// hand `token` to the client / embed / peer — it is an opaque bearer
}
The returned string is the encoded token. Everything security-relevant is host-forced, so a malicious or buggy guest cannot widen it:
- Audience is your own project. The guest never names the audience — the host stamps the minting project, so a token is never redeemable anywhere else.
- TTL is clamped to the operator ceiling
max_guest_capability_ttl_secs; a longer request is silently clamped down, never up. - Deny-by-default. Minting is gated by the
allow_guest_mint_capabilityposture knob (off undermulti-tenant). Without thecapabilitygrant, or with the knob off, there is no binding andmintreturnsaccess-denied. - The
app-contextis size-bounded (a few small entries) and carried with integrity; the host never interprets it.
Redeem it
The bearer is presented on a GraphQL field whose source is via: [capability] (see
Cross-tenant target fields). At redeem the host
verifies the capability once — audience == project, a non-expired exp, and the capability’s
subset matching an operator-declared, target-eligible route’s public — and confines the query
to tenant = B.
The enforcement ceiling lives at redeem, not mint. A minted token is inert wherever the
operator has not opened a matching via: [capability] route: the capability carries no write
allowlist (the route does), and a subset that no route accepts resolves nothing. So a guest can
never mint past the operator’s declaration, for another project, or for more than the project
already holds — the mint side only enforces audience-forcing, the TTL clamp, and the size bounds.
Read the app-context back (target-context)
The via: [capability] field confines to tenant = B, but a per-client filter (e.g. show
one client’s own invoices within B) is within-tenant authorization, not a tenancy axis — so
it stays in your resolver’s own query. Recover the opaque sub the capability carried with the
boatramp:handlers/target-context binding:
use boatramp:handlers/target-context.{get};
#![allow(unused)]
fn main() {
let ctx = get(); // list of (key, value) pairs; empty for any non-capability principal
let sub = ctx.iter().find(|(k, _)| k == "sub").map(|(_, v)| v.clone());
// add `client_id = sub` to your own query — the host floor (tenant = B) already applied
}
Two things make this safe to expose with no grant: the host-forced target tenant B is
never returned (only the app-authored context round-trips, preserving guest-blindness for
host facts), and the content is your own signed data (you minted it), so there is nothing to
leak. The list is empty for any own / session / domain / handle principal.
Target writes to a session table are refused
A capability can back a target write (with a route write-allowlist), but a target write to a
TenantOrSession (anonymous-session-keyed) table is refused fail-closed (since v0.4.5): a
target principal carries only B and no session fact, so such a write could only silently claim
an anon/session-owned row for B. Split the resolver — write the session-keyed rows on the
own/session path, never under a target scope.
See also
- Cross-tenant target fields — the
via: [capability]source that redeems a minted token. - Isolate tenants within a project — the access-mode model the target axis sits beside.
- Choose & inspect a security posture — the
allow_guest_mint_capabilityknob and the TTL ceiling.
Run consumers, crons, and streams
Background work runs as WebAssembly handlers that boatramp invokes for you
instead of per HTTP request: consumers process messages off a topic, and
crons invoke a route on a schedule. You declare each one in the routing
section of project.cfg, pointing it at a handler, and boatramp runs it for the
live deployment. For the component build and site policy, see
Deploy a handler.
Declare a consumer
A consumer is invoked once per message on its topic. Give it a retry budget: a
message that fails is retried up to max_attempts times, then dead-lettered.
routing: (
consumers: [
( topic: "emails", component: "mailer.wasm",
imports: ["sql", "wasi:messaging"],
max_attempts: 5 ),
],
),
Cap a consumer’s concurrency
All consumers share one async-lane concurrency budget (async_max_concurrency).
A burst in one consumer (say a thumbnail backfill of thousands of images) can
occupy the whole lane and starve the others. Give a consumer its own
max_concurrency to cap how many of its messages run at once, independent of the
rest of the lane:
routing: (
consumers: [
// At most 2 thumbnail decodes in flight; other consumers keep their headroom.
( topic: "bus:thumbnail.requested", component: "thumb.wasm",
imports: ["wasi:blobstore", "wasi:messaging"],
max_concurrency: 2 ),
],
),
The value is clamped to the lane ceiling (min(max_concurrency, async_max_concurrency)
— it can only narrow a consumer, never raise it above the operator budget). Unset
⇒ the consumer shares the lane as before (no change). This is pure admission
control: ordering, at-least-once delivery, and retry/dead-letter semantics are
unchanged. Pair it with async_max_memory_mb for a memory-heavy worker — cap the
concurrency so N concurrent runs fit the memory budget.
Share a topic across components: the project bus
A plain consumer topic is site-private — only that site’s own handlers publish to
it. To let different components talk over one topic — a handler in one site, a
function, or an external webhook — publish to and
subscribe from the shared project bus with a bus: prefix:
routing: (
consumers: [
// Subscribe to the project-wide `orders.created` bus topic.
( topic: "bus:orders.created", component: "fulfil.wasm",
imports: ["wasi:messaging"] ),
],
),
Anything in the project publishes to the same topic — a guest via wasi:messaging
(publish("bus:orders.created", …)), a function’s queue trigger, or a webhook
ingress. The bus is scoped to the project (a workspace): every member shares
it, and it is isolated from other projects. Producer and consumer are decoupled —
add or remove consumers without touching the producer.
Fan out to independent workers: consumer groups
By default the consumers on a topic form a work-queue: each message goes to
exactly one of them (competing consumers — add more to scale throughput). Give a
consumer a group and it becomes a durable fan-out subscriber instead — it
receives every message on the topic, on its own cursor, with its own retries and
dead-letters. Consumers in different groups each process every message:
routing: (
consumers: [
( topic: "bus:orders.created", component: "billing.wasm",
group: "billing", imports: ["sql"] ),
( topic: "bus:orders.created", component: "audit.wasm",
group: "audit", imports: ["wasi:blobstore"] ),
],
),
billing and audit each receive every order event; a slow or failing group never
blocks the other. A new group starts at start: latest (only events published after
it subscribes — the default) or start: earliest (replay the retained backlog):
( topic: "bus:orders.created", component: "reindex.wasm",
group: "reindex", start: earliest ),
Omitting group keeps the work-queue behaviour — unchanged.
Ingest external events
To bring an external event (a Stripe or GitHub webhook, a partner callback)
onto the bus without writing a consumer, deploy a function whose webhook
publishes to a bus topic. A signature-verified request drops its body onto the
bus and returns 202 — no code runs — and consumer groups process it like any
other event:
BOATRAMP_STRIPE_SECRET=… boatramp function deploy stripe-events \
--component ./noop.wasm \
--webhook-secret-env BOATRAMP_STRIPE_SECRET \
--webhook-publish payments.event
Callers POST /_webhooks/stripe-events with the signature header, and a verified
event lands on bus:payments.event. It stays deny-by-default — no secret ⇒
503, a missing or wrong signature ⇒ 401, an oversize body ⇒ 413 — so a
spoofed post never reaches the bus. (The --component is still required today but
is never run for a publishing webhook.) For the signature scheme, see
signed webhooks.
Declare a cron
A cron invokes an existing route on a schedule, using a standard five-field cron expression. The route runs as if a request arrived for it:
routing: (
crons: [
( schedule: "0 * * * *", route: "/api/rollup" ),
],
),
Sync to activate the new routing. Each component is validated at sync:
boatramp sync ./dist --site my-site
validated mailer.wasm — consumer topic "emails"
activated my-site -> a1b2c3d4
In a cluster a cron fires on the one node that owns it (a stable hash over the live membership), so cron work spreads across the fleet — it fires once per minute, cluster-wide, not once per node. During a rare membership change (a node joining or leaving) a single tick may be missed; crons are best-effort periodic, so a skipped minute during a reshuffle is expected, not a failure. On a single node this is unchanged — every scheduled minute fires.
Operate the dead-letter queue
When a message exhausts max_attempts, boatramp dead-letters it and retains the
payload until you clear it. Once you have fixed the cause, requeue the
dead-lettered messages onto the live topic:
boatramp dlq redrive emails --site my-site
redrive: 12 dead-lettered message(s) on topic "emails"
If the messages are unrecoverable, drop them and reclaim the space instead:
boatramp dlq purge emails --site my-site
purge: 12 dead-lettered message(s) on topic "emails"
To scope either command to a background alias rather than the live site, add
--alias {site}/{alias}.
Operate the shared project bus
The commands above manage a single site’s queues. To inspect or manage the
shared project bus — the bus:<topic> keyspace every site in a project
publishes to and consumes from — add --bus instead of --site:
boatramp dlq ls orders.created --bus
boatramp dlq redrive orders.created --bus
boatramp queue peek orders.created --bus
boatramp queue pause orders.created --bus
The project comes from your config’s [publish].project (or --project). Because
the bus is shared across the whole project, its destructive operations
(dlq redrive/discard/purge, queue group-reset/group-delete/pause) require
a project-admin token — stronger than the per-site write a site’s own DLQ needs;
inspection (dlq ls/show, queue peek/replay/groups) requires project-read. A
token scoped to one project can only ever reach that project’s bus. --bus and
--alias are mutually exclusive (the bus is not per-deployment).
Watch lag and dead-letters
Check consumer backlog and dead-letter counts with boatramp stats:
boatramp stats --site my-site
site my-site
queue/emails invocations 512 errors 1 lag 0 dead-letters 0
A growing lag means consumers are falling behind the incoming rate; a nonzero
dead-letter count is messages waiting for you to redrive or purge. For tailing
guest output and the full metric surface, see
Observe a running server.
Publish durability: strong by default
publish() returns only after the message is crash-durable — written and
flushed to the durable store (its WAL persisted to object storage), so an
acknowledged publish survives a process crash and a power loss. This is a
stronger guarantee than NATS JetStream’s default synchronous publish, which
acknowledges once the message is in the server’s memory with the fsync deferred.
That safety costs latency: a single sequential publisher pays roughly one flush
interval per message. Concurrent publishers amortize it — the per-node
group-commit coalesces everything landing in one flush window into a single
durable write — and publish_batch commits a whole batch in one flush, so the
throughput you care about for a fan-out fabric stays high while every ack means
persisted. Reach for a weaker mode only if single-publisher latency is your
bottleneck after batching.
Opt into fast-ack (messaging_max_unflushed_msgs)
If a workload needs JetStream-like publish latency and can tolerate a bounded
loss window, an operator can set, in the node’s [handlers] config:
[handlers]
messaging_max_unflushed_msgs = 256 # 0 (default) = strong durability
With N > 0, publish() acknowledges on the in-memory buffer insert (≈tens of
µs) and a durable checkpoint is forced every N messages. The trade is explicit:
- What you gain: single-publisher publish drops from ≈one flush interval to ≈tens of µs; aggregate throughput rises accordingly.
- What you give up: acknowledged-but-unflushed messages are lost on a
process crash, OOM,
SIGKILL, or power loss before the next flush. The loss window is bounded by both count and time — at mostNmessages and at most one storeflush_interval(the background WAL-flush timer persists every buffered write within one interval regardless of publish activity, ~5 ms in a boatramp deploy vs JetStream’s 2 s fsync interval), whichever comes first. So a slow trickle can’t leave a message un-durable longer thanflush_interval, and a burst can’t leave more thanNun-durable. PickNagainst your tolerance. - Honest positioning: this is weaker than the strong default, and — because boatramp’s buffer is in process memory — also weaker than JetStream’s default (whose page-cache ack survives a process crash; boatramp’s does not). It is faster than both. It is not “JetStream parity.”
Scope and safety:
- Bus publish only. Consumer at-least-once is unchanged: a message that was flushed still redelivers on lease expiry, and ack/claim/dead-letter transitions are always fully durable. The only new failure is a just-published, not-yet-flushed message vanishing on a crash.
- Control plane is never affected. Deploy/config/domain/auth state and guest
wasi:keyvaluewrites stay fully durable regardless of this knob, even though they share the same store. - Operator-only, single-node. It lives in daemon config (a site can’t set
it); a node with
N > 0logs a warning at startup. In a cluster, publish durability is replication and this knob has no effect.
Build a duplex agent session (SSE + resume)
A session is a long-lived, resumable, bidirectional channel between a client and a
WebAssembly guest — the primitive for streaming agent UIs (AG-UI, chat, tool-call
streams). The client opens an SSE stream to receive frames and POSTs frames back
on the same route; boatramp owns the ordering, buffering, resume, and lifetime, and
re-enters your guest once per inbound frame rather than holding a long-lived
instance. Every frame is opaque bytes — boatramp never parses your protocol, so
you can carry AG-UI events, JSON, or anything else.
A session differs from a stream (host-only pub/sub fan-out, no guest, no backchannel) and from a plain streaming handler (one request, one response body): a session runs your code per client message and streams results back, across reconnects.
The
sessioncapability is experimental and ships in the default build behind thesessioncargo feature. A component that uses it declaresrequires = ["session"], so deploying it to a host build without the capability is refused cleanly (see capability compatibility).
Declare a session route
Add a sessions entry to your site’s routing in project.cfg, pointing it at a
component that exports the session handler:
routing: (
sessions: [
( route: "GET /agent",
component: "agent.wasm",
// Extra per-frame capabilities the handler uses. The session backchannel
// itself is intrinsic to the route — you may list `session` for clarity, but
// `sql`/`orm`/`invoke`/… are what actually need granting.
imports: ["session", "sql"],
// Host-forced tenancy for any sql/orm the handler runs per frame, resolved
// ONCE at open from the verified bearer and carried across every re-entry
// (identical to a handler — see Isolate tenants within a project).
tenancy: (mode: "scoped", column: "tenant_id", sources: [(kind: "token", claim: "org")], read: "own", write: "own"),
token_claims: ( jwks_url: "https://issuer/.well-known/jwks.json",
issuer: "https://issuer/" ) ),
],
),
The client addresses one session by a ?id=<id> query parameter it chooses (see the
wire protocol); the host namespaces it under your project,
so ids never collide across tenants.
Write the handler
The host calls your handle export once per inbound frame batch with the pending
frames and the last checkpoint. This is mechanism B: no in-memory state survives
between re-entries — you rehydrate from the checkpoint each time and persist the next
one before returning. You get three host calls: send (enqueue an outbound frame,
ordered, host-buffered for at-least-once resume), checkpoint (persist opaque resume
state), and close (end the session).
With the shim the boilerplate is a macro:
#![allow(unused)]
fn main() {
use boatramp_uchron_compat as compat;
use compat::session::Session;
use compat::CompatError;
#[compat::session(route = "GET /agent", requires = ["session"])]
fn handle(mut s: Session) -> Result<(), CompatError> {
// Rehydrate turn count from the resume checkpoint (never a static/in-memory value).
let mut turns: u32 = s.resumed()
.and_then(|b| b.try_into().ok())
.map(u32::from_le_bytes)
.unwrap_or(0);
while let Some(frame) = s.recv() { // drain this batch's inbound frames
if frame.as_slice() == b"cancel" { // (recv() -> None means "drained", not closed)
return s.close("client cancel");
}
turns += 1;
s.send(format!("event: turn {turns}\n").as_bytes())?; // opaque bytes out
}
s.checkpoint(&turns.to_le_bytes())?; // survives the next re-entry
Ok(())
}
}
Raw wit-bindgen guests export boatramp:handlers/session-handler and import
boatramp:handlers/session directly — see examples/handlers/session-echo for a
complete, dependency-free example (it backs the live capability gate).
Because delivery is at-least-once, a re-entry may run again after a trap or a
reconnect — so make handle idempotent with respect to its own effects (gate them
on the checkpoint, or tolerate a replay). Frames your handler already send-committed
before a mid-batch trap are kept; the inbound frame redelivers.
The client wire protocol
A session is served on its route as SSE out + POST in, keyed by ?id=:
Receive — open the SSE stream:
GET /agent?id=<id> Accept: text/event-stream
GET /agent?id=<id> Last-Event-ID: <cursor> # resume after a drop
Each outbound frame arrives as an SSE event named frame, whose data: is the
base64 of your opaque payload and whose id: is a monotonic cursor. On
reconnect the browser’s EventSource sends the last id: as Last-Event-ID
automatically, and the host replays only frames past that cursor. A terminal
event: close (its data: the close reason) means the session ended.
Send — POST an inbound frame body to the same route:
POST /agent?id=<id> # body = one opaque frame
POST /agent?id=<id> Idempotency-Key: <key> # dedupe a retried POST
POST /agent?id=<id>&ack=<cursor> # ack received frames (GC the buffer)
A POST returns 202 once the re-entry committed, 200 if it was a de-duplicated
retry, 410 if the session is closed, and 400/413 for a bad id / oversized frame.
Supply an Idempotency-Key to make retries safe (delivered once), and ack the highest
cursor you’ve received periodically so the host can release the outbound buffer during a
long stream.
Security & isolation
- Cross-tenant isolation is structural. The session record is keyed under the verified caller’s project; no id can reach another tenant’s session.
- A session id is a bearer capability within a tenant. Binding re-verifies the
caller’s tenant on every open/POST (a different tenant is refused
403), but two users of the same tenant are not distinguished — so use an unguessable id (a UUID; the shim generates one) and don’t treat a session as a per-user boundary beyond the tenant. - Tenancy is host-forced. Any
sql/ormyour handler runs per frame is scoped to the tenant resolved at open — never guest-spoofable, fail-closed, exactly as for a handler.
Limits & lifetime
Sessions are bounded by host defaults: 1 MiB per frame, 256 unacked outbound frames
(backpressure past that), a 256-entry inbound dedup window, a 4 MiB checkpoint, a
5-minute idle TTL, and a per-project live-session cap. An idle or closed session is
reaped automatically; its client simply reconnects (with Last-Event-ID) if it still
wants the feed. Per-scope and per-IP connection caps apply to both the SSE and the POST
side, shared with the stream budget.
Deploy & invoke a function
A top-level function is a WASI component you deploy and call by name, with its own version line — independent of any site deployment. Use it when you want a unit of compute that is invoked directly (sync or async), versioned and rolled back on its own, and reused across sites. For the concept, see Functions: the compute primitive; to run one behind a route instead, see Deploy a handler.
A function is owned by a project, just like a site. Every boatramp function …
command respects the global --project flag (env BOATRAMP_PROJECT, falling back
to [publish].project, then the reserved default project), and a function name is
unique only within its project — so acme/resize and beta/resize are distinct
functions.
All of the commands below take --server <url> (or read it from project.cfg) and
require a token with system·admin for writes / invoke, system·read for reads —
or a project role scoped to the function’s project (project_admin:<proj> for
writes / invoke, project_viewer:<proj> for reads).
Scaffold a new function
Start from a template instead of hand-wiring a wasi:http component:
$ boatramp function init greeter
scaffolded greeter in ./greeter
next: cd greeter && boatramp function build
$ cd greeter && boatramp function build
built target/wasm32-wasip2/release/greeter.wasm
deploy: boatramp function deploy <name> --component target/wasm32-wasip2/release/greeter.wasm
function init writes a minimal component (a handle function you edit) plus its
wit/ world; function build compiles it and prints the produced component,
detecting the language from the project files:
--lang rust(default) — awasi:httpcomponent built withcargo build --release --target wasm32-wasip2. Needs thewasm32-wasip2target (rustup target add wasm32-wasip2, or the project’snix developshell).--lang js— a JavaScript component built withjco componentize(fetched version-pinned vianpx, so only Node is required;nix developprovides it).--lang python— a Python component built withcomponentize-py(run version-pinned viauvx, so onlyuvis required;nix developprovides it).
The produced .wasm is a portable WASI component in every case — deploy it with
function deploy, and it runs on the same engine. Note the JS and Python
components bundle their language runtime (~12–18 MB) and so are larger than a Rust
component; pick the language that fits your code.
Run it locally
Before deploying, exercise the component locally — no server, no upload. The harness runs the component in-process through the same engine that serves it in production:
# One request + assertions (exits non-zero if an assertion fails):
$ boatramp function test --component target/wasm32-wasip2/release/greeter.wasm \
--path /hello --expect-status 200 --expect-body "hello"
HTTP 200
hello from your boatramp function (/hello)
ok
# Or serve it on a local port and curl it:
$ boatramp function dev --component target/wasm32-wasip2/release/greeter.wasm --port 8787
serving …/greeter.wasm on http://127.0.0.1:8787 (Ctrl-C to stop)
The harness grants no host capabilities (kv/sql/blobstore/messaging), so it suits
components that only use the HTTP request/response — capability-backed local
testing comes later. function test/dev are in the build compiled with the
handlers feature (the engine).
Deploy a version
Deploy a component .wasm as a named function. The CLI uploads it as a
content-addressed blob first, then registers the version:
$ boatramp function deploy greeter --component ./greeter.wasm
deployed greeter [wasm] a1b2c3d4e5f6
The printed id is the version — the component’s content hash. Deploying the
same bytes again is idempotent; deploying new bytes appends a version and makes it
active. Choose a stronger runtime substrate with
--runtime microvm (or container).
List and inspect
$ boatramp function ls
greeter [wasm] a1b2c3d4e5f6 invoke:greeter
$ boatramp function get greeter
greeter
runtime: wasm
version: a1b2c3d4e5f6
Invoke it
A sync invoke runs the function inline and streams back its response. The
request body is sent to the function; --data / --data-file supply it:
$ boatramp function invoke greeter --data '{"name":"Ada"}'
Hello, Ada!
An async invoke durably enqueues the call and returns an id to poll — the run survives a restart and is retried, then dead-lettered, on failure:
$ boatramp function invoke greeter --async --data '{"name":"Ada"}'
queued 7f3a… [queued]
$ boatramp function invocation greeter 7f3a…
7f3a… [succeeded] attempts=1
result: HTTP 200
Long-running jobs run async, not sync
A synchronous invoke is connection-bearing — a client, a proxy, and the shared
request pool all block while it runs — so it is held to a tight ceiling
(handlers.sync_max_timeout_ms, default 10s). A route or function that declares
a longer timeout_ms on the sync path is clamped back down to it.
Genuinely long work — an LLM generation, a batch transform — belongs on the
async path. The drain that runs --async invocations (and workflow steps,
cron/queue/blob triggers, messaging consumers) is held to a much larger ceiling
(handlers.async_max_timeout_ms, default 15 min) on its own concurrency budget,
so a long background job runs to completion without ever blocking live site
traffic. A function’s declared timeout_ms takes effect up to that async ceiling.
Raise the ceiling for a deployment that needs longer:
// boatramp.cfg — allow async jobs up to 30 minutes.
handlers: ( async_max_timeout_ms: 1800000 ),
A claimed async run carries a lease, so if the node dies mid-run another drain reclaims and retries it once the lease elapses — the job is never silently lost. Work that needs to run longer than one async ceiling should be a workflow: each step is its own bounded invocation, so no single run is pinned for the whole duration and each step is independently retried.
Idempotency
Pass --idempotency-key <key> to make an invoke safe to retry: a repeat with the
same key replays the first call’s outcome instead of running the function
again. This holds for both sync and async.
$ boatramp function invoke greeter --idempotency-key order-42 --data '…'
Versions, aliases, and rollback
A top-level function carries its own version line, so you can promote and roll back without touching any site:
# Point a label at a version (e.g. a stable "prod" alias).
$ boatramp function alias greeter prod a1b2c3d4e5f6
# Invoke a specific version or alias instead of the active one.
$ boatramp function invoke greeter --version prod
# Roll the active version back to an earlier one.
$ boatramp function rollback greeter --to a1b2c3d4e5f6
Usage & quotas
Every invocation is metered host-side. Read the aggregate:
$ boatramp function usage greeter
greeter
invocations: 128 (126 ok, 2 failed)
duration: 5310 ms total
bytes: 40960 in / 81920 out
The same counters are exported as Prometheus series
(boatramp_function_invocations_total, …_failures_total,
…_duration_ms_total) — see Observe.
A function may declare a quota in its config, enforced fail-closed (over the
limit ⇒ 429):
max_invocationsover awindow_secswindow — a fixed-window rate limit.max_concurrent— the most in-flight invocations at once (per node).
Scheduled & event triggers
A top-level function can also be reached by a trigger the server dispatches on
its own — no caller. Add one with function trigger add:
# Run the function on a schedule (a durable async invocation each fire).
$ boatramp function trigger add greeter tick --cron "0 * * * *"
# Invoke the function per message on its queue `fn/greeter/jobs`.
$ boatramp function trigger add greeter jobs --queue jobs
# Invoke the function when an object changes under `fn/greeter/uploads/`.
$ boatramp function trigger add greeter onupload --blob uploads/
$ boatramp function trigger ls greeter
jobs [queue]
onupload [blob]
tick [cron]
$ boatramp function trigger rm greeter tick
- A cron fire enqueues a durable invocation (retried, then dead-lettered, like any async invoke).
- A queue trigger claims messages from the function’s own
fn/<name>/<topic>topic and invokes the function once per message, acking on success. - A blob trigger fires when an object changes under the watched prefix — and it
fires for any writer, not just boatramp, because it uses the storage
backend’s native change notification (inotify/FSEvents locally, S3→SQS in the
cloud). The changed key + kind arrive as the invocation’s JSON body. It needs a
watch-capable storage backend: on one that can’t watch, adding the trigger is
refused (a
400, never a silent no-op). In a cluster each trigger fires on the one node that owns the function (a stable hash over the live membership), not the leader, so the watch work spreads across the fleet; a change is enqueued exactly once cluster-wide. A change that arrives while the previous run for the same key is still in flight coalesces into that run (a debounce) rather than queuing a second run; a change after the previous run has settled re-fires normally.
Cloud blob triggers (auto-provisioning)
The function trigger add --blob command is identical on every backend — the
environment difference hides behind the storage backend. On the filesystem the
watch is zero-config (inotify/FSEvents). On a cloud object store the native event
pipeline must be created first, so boatramp provisions it for you — “auto-DNS, but
for object-store events.” What boatramp creates is recorded in a managed-notification
ledger and retracted when you remove the trigger, so no cloud resources leak.
Each cloud backend uses its native pipeline:
- S3 (
--blobs s3) — an SQS queue + a queue access policy + a bucketQueueConfiguration(added by read-merge-write, so existing notifications are preserved and an overlapping foreign entry is refused, never clobbered). Fully auto-provisioned. - GCS (
--blobs gcs) — a Pub/Sub topic + subscription + a bucketnotificationConfig. Auto-provisioned except the one-time IAM grant giving the GCS service agentroles/pubsub.publisheron the topic (thedry-runrecipe prints it). - Azure (
--blobs azure) — a Storage Queue (auto-provisioned) fed by an Event Grid subscription. The Event Grid subscription is a one-time management-plane (Azure AD) step thedry-runrecipe prints as anaz eventgridcommand; boatramp manages + consumes the queue.
You pick the behavior with a tier in the server’s boatramp.cfg (the elevated
cloud credentials live server-side, not in the CLI):
serve: (
// dry-run | provision | verify-only | refuse (default)
blob_notify_tier: "provision",
// S3: the AWS account id (scopes the SQS queue policy).
// GCS: the GCP project id (for the topic + notificationConfig).
// Azure: unused (the queue shares the account's shared-key auth).
blob_notify_account_id: "123456789012",
)
dry-run— adding the trigger prints the exact pipeline to apply and does not activate (nothing is mutated, no credentials needed).provision— boatramp creates + reconciles + retracts the pipeline (needs credentials allowed to manage SQS + the bucket notification config).verify-only— you pre-wired the pipeline; boatramp checks it exists, then consumes it.refuse(default) — no pipeline, no provisioning ⇒ the trigger is refused (fail-closed). This is why a cloud blob trigger with no tier configured is a400: the behavior stays conceptually clear, never a silent no-op.
Signed webhooks
To let an external system trigger a function over a public, signature-verified endpoint, deploy it with a webhook secret reference:
$ BOATRAMP_HOOK_SECRET=… boatramp function deploy ingest \
--component ./ingest.wasm --webhook-secret-env BOATRAMP_HOOK_SECRET
Callers then POST /_webhooks/ingest with an X-Boatramp-Signature header holding
the HMAC-SHA256(body, secret) hex (a leading sha256= is accepted). boatramp
verifies the signature constant-time, before the function runs — a missing or
wrong signature is 401, and the secret lives only in the host env var you named,
never in the stored config.
Add --webhook-publish <topic> to make the webhook an ingress instead: a
verified request publishes its body onto the project bus at that topic (and returns
202) rather than running the function — bringing external events into a
message-queue-connected system through one hardened door. See
Ingest external events.
Remove it
$ boatramp function rm greeter
removed greeter
Content-addressed component blobs are shared, so removal leaves them for
prune.
Orchestrate functions with workflows
A workflow chains functions into a small DAG with durable state, retries, barrier joins, and on-failure compensation. Reach for one when a job is several steps that must run in order (or fan out and rejoin) and you want the run to survive a restart and roll back cleanly if a step fails. For single calls, invoke a function directly; a workflow is the multi-step case.
A workflow is deliberately small — a DAG of function invocations, not a general
BPMN engine. Each step invokes one function’s active version; edges are
depends_on.
Writes need system·admin; reads need system·read.
Define a workflow
Write the steps as JSON and define the workflow by name:
[
{ "id": "extract", "function": "pull-orders" },
{ "id": "transform", "function": "normalize", "depends_on": ["extract"] },
{ "id": "load", "function": "write-warehouse", "depends_on": ["transform"] }
]
$ boatramp workflow define etl --file ./etl.json
defined workflow etl
The DAG is validated on define — unique step ids, resolvable dependencies, and no
cycles. A cycle (or a dangling dependency) is rejected with 400.
Chain, fan-out, and fan-in
The edges express the shape:
- Chain — a linear
depends_on(a→b→c). - Fan-out — several steps that each
depends_onthe same upstream step; they become ready together. - Fan-in / barrier join — a step that
depends_onmany steps runs only once all of them have succeeded.
[
{ "id": "root", "function": "seed" },
{ "id": "a", "function": "work", "depends_on": ["root"] },
{ "id": "b", "function": "work", "depends_on": ["root"] },
{ "id": "join", "function": "reduce", "depends_on": ["a", "b"] }
]
A step receives the run’s input (root steps) or a JSON object mapping each dependency’s id to its output (downstream steps), as its request body.
Start and poll a run
$ boatramp workflow run etl --data '{"since":"2026-07-01"}'
started run 9c1e… [running]
$ boatramp workflow run-status etl 9c1e…
9c1e… [succeeded]
extract: succeeded (attempts=1)
transform: succeeded (attempts=1)
load: succeeded (attempts=1)
A run is durable: the executor advances it on the server’s scheduler, so it continues across restarts, and in a cluster each run is driven by the leader exactly once.
Retries and compensation
Give a step a retry budget and a compensation function:
[
{ "id": "charge", "function": "charge-card", "retry": { "max_attempts": 3 },
"compensate": "refund-card" },
{ "id": "ship", "function": "create-shipment", "depends_on": ["charge"] }
]
- A failed step is retried up to
max_attempts(default1= no retry). A delivery failure is a5xxfrom the engine (a trap, timeout, or a missing component); a response the function itself returns — even a4xx— counts as a successful delivery. - When a step finally fails, the run fails and each already-succeeded step’s
compensatefunction runs in reverse completion order — the saga rollback. In the example, a failedshiptriggersrefund-cardfor the completedchargestep, which is then markedcompensated.
Manage definitions
$ boatramp workflow ls
etl (3 steps)
$ boatramp workflow get etl
etl
extract -> pull-orders
transform -> normalize (after extract)
load -> write-warehouse (after transform)
$ boatramp workflow rm etl
removed workflow etl
Removing a definition leaves past runs as history; prune
clears them.
Run a container or microVM
A compute workload runs a long-lived server — a container image or a microVM — behind a route, next to your static content and Wasm handlers. Use it when a Wasm handler is not enough: an existing container image, a language runtime, or code that needs a full OS. For the choice between a handler, a container, and a microVM, see Compute: handlers vs containers vs microVMs.
Compute backends are Linux-only and capability-detected at startup: a container
backend where the host allows it, and a microVM backend where /dev/kvm exists.
Enable compute by adding a compute: section to boatramp.cfg (see the
schema).
Provision a kernel
Every workload boots in a microVM, which needs a kernel as well as a root
filesystem. Supply a Firecracker-compatible uncompressed Linux kernel (vmlinux)
— build one, or use a released microVM kernel — provisioned once and shared across
every workload.
--kernel (like --tar / --rootfs) accepts any of three forms: a local file, a
URL, or a blob hash already in the store. Point it straight at a file or
URL and the CLI uploads it for you:
boatramp compute build web --image nginx:1.27 --kernel ./vmlinux --port 80
# or a URL:
boatramp compute build web --image nginx:1.27 \
--kernel https://example.com/vmlinux-6.1 --port 80
To upload a kernel once and reuse its hash across commands, use
blob put:
boatramp blob put ./vmlinux
1a2b3c4d… # the content-address; pass it as --kernel 1a2b3c4d…
The kernel and its trust
You do not have to pass --kernel on every workload. A node has a fleet default
kernel — a dynamic setting you change without a
restart. boatramp distributes a first-party signed microVM kernel
(boatramp-vmlinux); set
it up once by uploading the released vmlinux as a blob and pointing the default
kernel at that content hash:
# 1. fetch the signed release (kernel + its .sha256 + .sig) and upload the kernel as a blob
base=https://github.com/BoatRamp/boatramp-vmlinux/releases/latest/download
curl -fsSLO "$base/boatramp-vmlinux-x86_64"
curl -fsSLO "$base/boatramp-vmlinux-x86_64.sha256"
curl -fsSLO "$base/boatramp-vmlinux-x86_64.sig"
boatramp blob put boatramp-vmlinux-x86_64 # prints the blob hash == its sha256
# 2. point the fleet default at it (source = the blob hash; sha256 + sig from the release
# artifacts, so this stays correct across releases)
boatramp config set compute.default_kernel "{
\"source\": \"$(cat boatramp-vmlinux-x86_64.sha256)\",
\"sha256\": \"$(cat boatramp-vmlinux-x86_64.sha256)\",
\"sig\": \"$(cat boatramp-vmlinux-x86_64.sig)\"
}"
source is the blob hash the backend stages (not the release URL). A workload
that omits --kernel uses this default. Changing it retargets new microVMs and
reboots; in-flight guests keep their kernel until they cycle.
The kernel is verified before boot, scaled by the security posture:
- Always: the kernel bytes must hash to the pinned
sha256— a mismatch never boots. multi-tenant(strict): the hash must be on the static[compute].kernel_allowed_hashesallow-list and carry a signature verifying against a static[compute].kernel_signing_pubkeyskey. So an admin token can only select a kernel the host operator pre-vetted and signed — never introduce a new one.single-tenant/dev: a verified hash pin suffices.
boatramp ships a first-party signing public key built in, so the signed default
kernel it distributes verifies out of the box. boatramp security explain shows
the resolved kernel-trust bar.
Kernels are per guest-arch (macOS vmm-vz)
The guest kernel matches the backend’s guest architecture: the Linux/KVM embedded
VMM boots an x86_64 vmlinux, while the macOS Virtualization.framework backend
(vmm-vz, Apple silicon) boots a raw arm64 Image. An x86_64 kernel can’t boot
an arm64 VM, so [compute].kernel_allowed_hashes is arch-scoped — an Apple-silicon
node trusts only boatramp-vmlinux-aarch64 releases, an x86_64 node only the
x86_64 ones — and --kernel / compute.default_kernel on macOS must point at an
arm64 kernel (the release’s boatramp-vmlinux-aarch64 asset, or any uncompressed
arm64 Image). Everything else — --kernel, the fleet default, verify-before-boot
— is identical. Under single-tenant / dev the content-hash pin alone suffices,
so vmm-vz runs with any operator-supplied arm64 kernel; the strict posture on
Apple silicon needs the signed boatramp-vmlinux-aarch64 release (its hash is baked
into the arch-scoped allow-list on release). An operator-supplied arm64 kernel must
enable the generic PCIe host + virtio-pci (CONFIG_PCI, CONFIG_PCI_HOST_GENERIC,
CONFIG_VIRTIO_PCI): Virtualization.framework presents its virtio disk/net/console
over a PCIe host bridge, so a CONFIG_PCI-off kernel finds no devices and never boots.
The boatramp-vmlinux-aarch64 release is built this way.
Deploy a container image
compute build takes an OCI image reference, builds an ext4 root filesystem from
it, uploads it, and registers the workload in one step. It needs the mke2fs
tool (e2fsprogs) on the host and a kernel blob provisioned once.
boatramp compute build web \
--image nginx:1.27 \
--kernel <vmlinux-blob-hash> \
--port 80 \
--vcpus 1 --mem-mib 256 --replicas 2
built ext4 rootfs from nginx:1.27 (1024 MiB) — blob sha256:1a2b…
workload web set: 2 replicas, port 80, isolation trusted
The scheduler places the replicas on nodes that advertise compute capacity and reconciles them toward the desired count. Check status:
boatramp compute ls
NAME REPLICAS PORT ISOLATION STATE
web 2/2 80 trusted Healthy
Choose the isolation level
--isolation decides which backend may run the workload:
--isolation | Runs on | Use for |
|---|---|---|
trusted (default) | a container (shared kernel) or a microVM | your own images |
untrusted | a microVM only (never a shared kernel) | third-party or tenant code |
boatramp compute build tenant-app --image ghcr.io/acme/app:1.4 \
--kernel <vmlinux-blob-hash> --port 8080 --isolation untrusted
Under the strict multi-tenant security posture, shared-kernel (container)
compute is disabled, so every workload runs in a microVM regardless of
--isolation. See Choose a security posture.
Set a workload from an existing source
compute set registers a workload from a root-filesystem source — exactly one
of, matched to the substrate you want:
--image <ref>— an OCI image reference the runtime pulls (docker/cloudflare).--tar <hash|file|url>— a tar rootfs archive the nativecontainerruntime unpacks.--rootfs <hash|file|url>— a rootfs filesystem image (a block device;ext4by default) thefirecrackermicro-VM attaches.
# A registry image on the docker backend (e.g. a database):
boatramp compute set pg --image pgvector/pgvector:pg16 --port 5432 --env POSTGRES_PASSWORD=pw
# A pre-built ext4 rootfs + kernel on the micro-VM backend:
boatramp compute set api \
--rootfs <rootfs-blob-hash> --kernel <vmlinux-blob-hash> \
--port 8080 --replicas 3 \
--entrypoint /usr/bin/api --env LOG=info
Inspect a workload’s desired state:
boatramp compute get api
Docker workloads: read-only root, writable root, and volumes
A docker (or native-container) workload runs hardened by default: a read-only
root filesystem, all Linux capabilities dropped, no privilege escalation, and a PID
cap. The idiomatic path for app writes is a persistent volume, not a writable
root — attach one (in-guest mount → named backing) via the API or a project.cfg
manifest, and the data persists across restarts.
For an image that insists on writing outside a declared volume, --writable-root
relaxes only the read-only-root default (every other hardening stays on):
boatramp compute set legacy-app --image acme/legacy:1 --port 8080 --writable-root
--writable-root is honored only under the single-tenant security posture — the
strict multi-tenant guard forces the hardened read-only root back on (and, being
shared-kernel, won’t place the workload on docker at all). See
Choose a security posture.
How the docker backend stores a volume is set by [compute].docker_volume_mode:
named (default) uses a daemon-managed docker volume (portable across daemons and
Docker Desktop / macOS); bind uses a host directory under
<data_dir>/compute/volumes/<name> (local daemon only). Either way the volume is
node-local — it is not part of the blob-snapshot durability story the microVM
backend’s volumes get, and does not follow a workload across nodes.
Running a stock image that needs privileges (e.g. a database)
Because every capability is dropped, a stock image whose entrypoint runs as root and
then chowns a data dir and drops to its own user (the classic postgres /
mysql init) can’t initialize on the shared-kernel backends out of the box — the
chown needs CAP_CHOWN/CAP_FOWNER and the privilege-drop needs
CAP_SETUID/CAP_SETGID. Two ways to make it work, cleanest first:
Run it rootless (preferred). Point the entrypoint at the image’s own DB user with
--user, backed by a persistent volume boatramp pre-owns for that uid — the entrypoint
then skips both the chown and the privilege-drop, so it needs no capabilities and
works under any posture:
boatramp compute set pg --image postgres:16 --port 5432 \
--user 999:999 --volume pgdata:/var/lib/postgresql/data
Add back the capabilities (fallback). For an image that won’t run rootless,
--cap-add grants specific capabilities on top of the dropped-ALL default. It is
honored only under the single-tenant posture (the multi-tenant guard strips it,
same as --writable-root); on the native-container backend the caps are bounded by the
workload’s user namespace:
boatramp compute set pg --image postgres:16 --port 5432 \
--cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \
--cap-add SETUID --cap-add SETGID \
--volume pgdata:/var/lib/postgresql/data
Managed databases do this for you. When a handler sql binding is sourced from a
database boatramp runs (see Managed SQL), boatramp applies a
privilege strategy automatically — no --user/--cap-add needed. The strategy is
[compute].managed_db_privilege: rootless (the default — run as the image’s DB user
against its pre-owned volume, no capabilities, any posture) or caps (add the minimal
set; single-tenant only).
Startup grace (slow-starting images)
A freshly launched replica gets a startup grace: the reconcile loop leaves a
still-unhealthy replica alone until the grace elapses, treating it as starting rather
than a broken launch to stop and relaunch. This keeps a slow-initializing image — a stock
database doing its first initdb — from being killed mid-init into a crash loop. Only
after the grace does a replica that is still unhealthy get stopped + relaunched (the
self-heal for a genuinely broken launch).
--startup-grace-secs sets it on any workload; omit it for the default (30s):
boatramp compute set worker --image acme/slow-boot:1 --port 8080 --startup-grace-secs 90
Managed databases raise it automatically. A managed co-located database uses a
larger per-engine default — Postgres 60s, MySQL 120s — because a stock database’s
first boot runs initdb before it opens its port. Override it per binding with
startup_grace_secs (or the env var
BOATRAMP_HANDLERS_SQL_DB_<NAME>_STARTUP_GRACE_SECS); omit it for the engine default.
Run a command inside a workload (compute exec)
compute exec runs a one-off command inside a running workload replica, docker-exec
style — the practical way to run a migration, take a pg_dump, or drop into a shell
to debug. It is available on the container and docker backends (not the
microVM backend), and is gated by the server’s allow_compute_exec security posture
knob (a 501 when it is off). Since 0.3.9.
Everything after -- is the command’s argv, so flags pass through untouched:
boatramp compute exec pg -- psql -U app -d appdb -c '\dt'
Pipe a file into the command’s standard input with --stdin — the classic way to
apply a migration or restore a dump:
boatramp compute exec pg --stdin -- psql -U app -d appdb < migration.sql
boatramp compute exec pg --stdin -- pg_restore -U app -d appdb < dump.pgc
The command’s stdout and stderr are printed and boatramp exits with the command’s
own exit code. It runs against a live, healthy replica; there is no guarantee of
which replica for a multi-replica workload, so target a single-replica workload (a
managed database is one) for state-changing commands.
Reach a sibling workload by name (internal DNS)
A workload can reach another workload in the same project — or that project’s
managed database — by name, without the control plane injecting a numeric
ip:port. boatramp runs a small DNS resolver on the compute bridge gateway and
points every container’s /etc/resolv.conf at it, so a guest resolves a peer with
either the bare short name or its fully-qualified internal name:
web— the bare workload name (thesearch <project>.boatramp.internalline in the container’s resolv.conf completes it), orweb.acme.boatramp.internal— the FQDN,<workload>.<project>.<domain>.
Either form resolves to the workload’s live, healthy replica IP from the
current reconcile state. A managed database is a workload too, so an app container
in project acme can reach its co-located Postgres as pg-<ident> (or by the
short name of whatever compute its sql binding names) — the same address the
sql binding injects, now reachable by name.
Resolution is isolated per project. The resolver maps the querying container’s
bridge IP to its (project, workload), so a tenant is only ever told an address in
its own project:
- an internal name in another project → refused (never resolved across the tenant boundary),
- an internal name that currently has no healthy replica →
NXDOMAIN, - an external name (or a query from a source that is not a known co-located container) → forwarded to the upstream resolver.
External DNS keeps working: anything outside a project’s internal namespace is
forwarded to compute.dns_upstream (default 1.1.1.1:53).
It is on by default on the Linux container backend. Turn it off, point external lookups at your own resolver, or rename the internal suffix with three knobs (all env-settable):
// boatramp.cfg
compute: (
internal_dns: true, // default; false leaves the image's resolv.conf untouched
dns_upstream: "1.1.1.1:53", // where external names are forwarded
dns_domain: "boatramp.internal",
)
Known constraints
- Compute is leader-node-only for now. The control plane schedules and reconciles workloads, but a workload’s replicas and its managed database do not yet span cluster nodes — a workload’s endpoints are node-local bridge IPs on the node that runs it. Run compute (and managed databases) on a single node, or on the cluster leader, until multi-node replica spreading lands.
- Internal name resolution is within a project. By design, a name resolves only inside the querying container’s own project — there is no cross-project name resolution. That is the tenant-isolation boundary, not a limitation to work around; to share a service between projects, front it with a route.
Manage persistent volumes
Unregistering a workload (compute rm) leaves its persistent volume on disk, so its
data survives an accidental delete and a re-set. List what’s on the node and reclaim a
volume you no longer need:
boatramp compute volume ls
NAME SIZE IN-USE
pg-acme_3f9c… 214 MiB yes
old-cache 12 MiB no
IN-USE marks a volume still referenced by a registered workload’s active spec.
Removing one of those would pull data out from under a running (or relaunching) replica,
so rm refuses it:
boatramp compute volume rm old-cache # ok — orphaned
boatramp compute volume rm pg-acme_3f9c… # refused: in use
boatramp compute rm pg-acme_3f9c… && \
boatramp compute volume rm pg-acme_3f9c… # the safe order
Pass --force to remove a still-referenced volume anyway (disposable data only — it
will be re-created empty on the next launch).
Next steps
- Scale compute to zero when a workload is idle.
- Load-balance & proxy upstreams to route traffic to it.
Scale compute to zero
A scale-to-zero workload snapshots and stops when it goes idle, then restores on the next request. You pay no CPU or memory for an idle service, and a cold request pays a restore instead of a full boot. It applies to microVM workloads, whose device-model state (including in-flight queue cursors) can be snapshotted and resumed.
Enable it per workload with --scale-to-zero:
boatramp compute build web \
--image nginx:1.27 --kernel <vmlinux-blob-hash> \
--port 80 --scale-to-zero
workload web set: 1 replica, port 80, scale-to-zero on
The workload runs normally under load. When it is idle, its state is snapshotted and the microVM stops; the next request restores it from the snapshot. A restore is faster than a boot because the guest resumes where it left off rather than re-initializing.
Note: the snapshot/restore mechanism is validated live (a serve → snapshot → restore → serve round-trip). The automatic idle→snapshot and request→restore reconcile is being finished; treat scale-to-zero as production-ready for the mechanism and pre-1.0 for the fully automatic idle detection. See Maturity, validation & support.
For the mechanism itself and when to choose scale-to-zero over always-on, see Compute: handlers vs containers vs microVMs.
Diagnose & work around compute issues
When a co-located workload or managed database misbehaves — a database that is reachable but “has no healthy replica”, a container stuck after launch, two replicas fighting over one IP — you need to see what the reconcile plane actually believes and, where possible, fix it live rather than wait for a new binary. These operator subcommands give you both.
They are node-global operator instruments: every one is gated at system·admin
(a project-scoped token cannot reach them, and cannot read another tenant’s
state), except sql ping, which is project-owned like the rest of the sql
family. Run them with an admin token (boatramp token mint --role admin, or your
configured operator token).
See what the reconcile plane believes
compute status prints the observed per-replica state — the exact record the
endpoint resolver reads to decide “is there a healthy replica to serve?”.
boatramp compute status # every workload, every tenant
boatramp compute status pg # just the `pg` workload
boatramp compute status --format json
PROJECT/WORKLOAD REP HEALTHY ENDPOINT PHASE AGE BACKEND
acme/pg 0 NO 10.0.0.2:5432 running 47s container
HEALTHY=NO on a running replica that has an endpoint is the signature of
“reachable but not served”: the container is up, but the stored health flag the
resolver gates on says otherwise. Confirm it is genuinely reachable with an
active probe:
boatramp compute netdiag pg # node → each replica TCP probe
boatramp sql ping # same, for a managed database's replicas
REP ENDPOINT REACHABLE HEALTHY PHASE BACKEND
0 10.0.0.2:5432 yes NO running container
REACHABLE=yes + HEALTHY=NO means the data plane is fine and the control
plane’s health record is stale — force-serve it (below). REACHABLE=NO points
at a real network or launch fault; keep digging with the IP and DNS views.
Find an IP collision
If two replicas were handed the same address, the gateway and internal DNS will
route unpredictably. compute ip ls lists every replica’s assigned IP and flags
duplicates:
boatramp compute ip ls
IP OWNER HEALTHY PHASE
10.0.0.2 acme/pg#0 yes running <-- COLLISION
10.0.0.2 globex/pg#0 yes running <-- COLLISION
1 duplicate IP(s) detected: 10.0.0.2
Restart one of the colliding replicas (below) to force a fresh allocation.
Check internal name resolution
compute dns ls shows the internal service-discovery map a co-located guest
resolves — each workload’s internal name and the healthy replica IPs it answers
with. An empty answer set means the name currently resolves to nothing.
boatramp compute dns ls
boatramp compute dns resolve pg # resolve one name in the --project tenant
NAME REPS HEALTHY ADDRS
pg.acme 1 10.0.0.2
web.acme 2 (none — unresolved)
Work around it live
Three levers, in increasing bluntness:
-
Force a reconcile pass — the loop converges now instead of at the next tick. Use it after fixing config, or to nudge a launch that needs re-attempting:
boatramp compute reconcile boatramp compute status # read the result -
Restart a replica — stop it and let the reconcile loop relaunch a fresh one, re-running IP allocation. The fix for a wedged replica or a collision:
boatramp compute restart pg 0 -
Force the stored health flag — the escape hatch when a recovered replica is stuck
healthy=false(so the resolver won’t serve it) and you have confirmed withnetdiag/sql pingthat it is genuinely up. This edits the control-plane record directly:boatramp compute set-health pg 0 --healthy true boatramp sql query 'SELECT 1' # the resolver serves it againset-healthis a manual override, not a fix — if the reconcile loop’s own probe disagrees on the next pass it will overwrite your value. Use it to restore service immediately, then address the root cause.
All of these target the --project tenant’s workload; pass --project <name>
(or set BOATRAMP_PROJECT) to act on a specific tenant.
Load-balance & proxy upstreams
The gateway reverse-proxies routes to backends you declare — a compute workload, a pool of servers, or a private service — with load balancing, health checks, and retries. You declare upstreams (backends) and routes (path → upstream) per site.
Proxy a route to one backend
boatramp gateway upstream add api http://10.0.0.5:8080 --site my-site
boatramp gateway route add /api --upstream api --site my-site
upstream api → http://10.0.0.5:8080
route /api → api
Requests to /api/* now forward to the backend. List what’s declared:
boatramp gateway ls --site my-site
Load-balance across a pool
Give several --backend URLs and a policy. round-robin (default) or random:
boatramp gateway upstream add api \
--backend http://10.0.0.5:8080 \
--backend http://10.0.0.6:8080 \
--lb round-robin --retries 1 --site my-site
--retries tries another backend on a connect failure (body-less requests only).
Route to the nearest region
With --lb nearest, the gateway sends each request to the nearest healthy
backend by region: tag each backend with --region URL=REGION, and name the
request header your CDN/edge sets with the client’s region via
--client-region-header (e.g. fly-region, cf-ipcountry):
boatramp gateway upstream add api \
--backend http://us.internal:8080 --region http://us.internal:8080=us-east \
--backend http://eu.internal:8080 --region http://eu.internal:8080=eu-west \
--lb nearest --client-region-header fly-region --retries 1 --site my-site
Selection is health-first, then by distance: an unhealthy nearest backend is
skipped for a healthy farther one (kept only as a last-resort fallback), and if the
client region is unknown the pool falls back to health-first order — never a hard
failure. By default nearness is binary (same region wins); to rank how far apart
regions are, set a distance table (region_map) in the site config directly.
Compute-backed pools tag themselves. When the upstream resolves its pool from a
compute workload (compute: <name>, replicas managed by the reconcile loop) rather
than static --backends, you don’t write a --region map: each replica is
auto-tagged with the region of the node it runs on — that node’s
[compute].region. Just set --lb nearest
--client-region-headeron the upstream and give each node a[compute].region, and every request goes to the nearest healthy replica.
To resolve the pool from DNS instead of listing backends, discover an A/AAAA record set:
boatramp gateway upstream add api \
--discover-host api.internal --discover-port 8080 --discover-ttl 30 \
--site my-site
Add health checks
Passive ejection removes a backend after consecutive failures and returns it after a cooldown:
boatramp gateway upstream add api \
--backend http://10.0.0.5:8080 --backend http://10.0.0.6:8080 \
--health-timeout-ms 5000 --site my-site
Active probing checks a path on an interval and requires a healthy status:
boatramp gateway upstream add api \
--backend http://10.0.0.5:8080 \
--probe-path /healthz --probe-interval-ms 10000 \
--probe-healthy 2 --probe-unhealthy 3 --probe-status 200 \
--site my-site
Rewrite the forwarded request
On a route, override the upstream Host header, strip a path prefix, and set
timeouts:
boatramp gateway route add /app --upstream api \
--host-header app.internal --strip-prefix /app \
--connect-timeout-ms 2000 --request-timeout-ms 30000 --site my-site
Tune upstream memory vs throughput
Each upstream connection keeps a read buffer, and a busy reverse proxy holds one per concurrent request — so at high fan-out that buffer is the dominant memory cost. It defaults to 32 KiB, a good balance for typical API/CDN responses. Raise it for large responses at low concurrency (fewer, larger reads = a bit more throughput), or lower it to trim memory on a high-fan-out, memory-tight node:
boatramp gateway upstream add api http://10.0.0.5:8080 \
--read-buffer-bytes 131072 --site my-site
Private and Unix-socket upstreams
Targeting a private IP or a unix: socket is gated by the operator
security posture: under the strict multi-tenant
default, a site cannot declare private-IP or Unix-socket upstreams, which blocks
a site from reaching internal services (an SSRF class). An operator enables them
per deployment with allow_site_private_upstreams / allow_site_unix_upstreams.
Warning: enable private or Unix-socket upstreams only for sites you trust. They let a route reach anything the server can reach on the host or private network.
Control caching
boatramp already sets a sensible Cache-Control on every file it serves, adds a
strong ETag, answers conditional requests with 304, and honors Range — you
do not configure any of that. This page covers the one thing you do control:
overriding Cache-Control per path, so hashed assets cache for a year and HTML
always revalidates.
When to override
Reach for a header rule when the automatic default is wrong for a path. Two cases cover almost everything:
- Long-lived immutable assets — files whose name changes when their content
does (
app.4f3a2b2c.js). Cache them for a year. - Always-revalidate documents — HTML, JSON feeds, anything that keeps its URL across deploys. Force a check on every request.
boatramp’s defaults already do this for content-hashed filenames and HTML. Add
rules when your paths do not match that shape (an unhashed /vendor/ bundle, a
hand-written /api/config.json), or when you want a blanket policy.
Set Cache-Control per path
Header rules live in project.cfg under routing.headers. Each rule has a path
matches pattern and a set map; every matching rule applies, in order.
(
routing: (
headers: [
// Fingerprinted assets — safe to cache for a year.
(matches: "/assets/**", set: {
"Cache-Control": "public, max-age=31536000, immutable",
}),
// Documents — always revalidate so a new deploy is picked up.
(matches: "**.html", set: {
"Cache-Control": "public, max-age=0, must-revalidate",
}),
],
// Blanket fallback for anything no rule matches.
cache: (default: "public, max-age=3600"),
),
)
A matching routing.headers rule wins; cache.default fills the gaps;
boatramp’s per-file defaults apply where neither is set. Rules are folded into
the immutable deployment at sync, so they roll back with the content. Run
boatramp validate to check the patterns before you publish.
Verify the response
Request an asset and read the headers back:
curl -sI https://my-site.example/assets/app.4f3a2b2c.js
HTTP/2 200
cache-control: public, max-age=31536000, immutable
etag: "9f86d081884c7d65..."
accept-ranges: bytes
vary: accept-encoding
The etag and accept-ranges are automatic. To confirm revalidation, send the
tag back — an unchanged asset answers 304:
curl -sI https://my-site.example/assets/app.4f3a2b2c.js \
-H 'If-None-Match: "9f86d081884c7d65..."'
HTTP/2 304
etag: "9f86d081884c7d65..."
Conditional routing varies automatically
If a conditional redirect/rewrite
decides the response from a request header (Accept-Language, a cookie, X-…),
boatramp adds the matching Vary header for you — e.g. a locale redirect
gets vary: accept-language. A shared cache then keys on that dimension and never
serves one visitor’s redirect to another. You don’t set this by hand; conditions
that read only the URL + deploy content (path, file_exists) add no Vary.
Cache handler responses at the edge
Everything above is about static files. A handler (a Wasm component) can
also opt into a host-level response cache that serves a cacheable GET/HEAD
response without re-instantiating the handler — the execution analogue of the
compile cache. It’s off by default; turn it on in the site’s handler config:
// boatramp.cfg — the site's handler config
handlers: (
enabled: true,
cache: (
enabled: true,
max_entry_bytes: Some(262144), // largest cacheable response; default 256 KiB
max_ttl_secs: Some(3600), // clamp an over-long max-age; default 3600s
),
)
The cache is opt-in per response, driven by the handler’s own headers — it never guesses. A response is stored only when all of these hold:
- the request is a
GETorHEAD, - the handler sets
Cache-Control: max-age=…(ors-maxage=…), - its size is known (
Content-Length) and withinmax_entry_bytes.
And it is never stored when the response is private:
Cache-Control: no-store,private, orno-cache,- it carries a
Set-Cookie, Vary: *, or- the request carried an
Authorizationheader and the response did not explicitly opt in withpublicors-maxage.
Entries are keyed by the request’s project-qualified scope (so two tenants
never collide), honor the response’s Vary header, and expire by TTL (clamped to
max_ttl_secs, lazily evicted on read). The cache is backed by the site’s KV
store.
With cookie auth. A cookie-authenticated request carries an
Authorizationheader (boatramp injects it from the cookie), so it inherits the rule above: a per-user response is not cached unless the handler explicitly marks itpublic/s-maxage. Never mark a per-user responsepublic— that would let it be stored and served to another user.
Reference
- Full
routingschema, includingcache.defaultand header-rule fields: project.cfg reference. - Handler
cachefields: SiteConfig reference. - Content negotiation and
Content-Encoding: Enable compression.
Enable compression
boatramp negotiates compression per request from the client’s Accept-Encoding.
Precompressed sibling variants are preferred over on-the-fly compression because
they cost no per-request CPU. This page covers both. For how compression
interacts with Cache-Control and ETag, see Control caching.
Ship precompressed variants
At sync, boatramp compresses compressible files and stores br and gzip
blobs next to the identity blob — an app.js gets app.js.br and app.js.gz
siblings. A variant is kept only when it is smaller than identity.
At serve time boatramp negotiates Accept-Encoding (brotli over gzip, honoring
;q=0 and *), returns the best variant the client accepts, and sets
Content-Encoding, a per-representation ETag, and Vary: Accept-Encoding.
Request the brotli variant:
curl -sI -H 'Accept-Encoding: br' https://my-site.example/app.js
HTTP/2 200
content-type: text/javascript
content-encoding: br
vary: accept-encoding
A client sending no Accept-Encoding — or identity — gets the uncompressed
blob and the same Vary header.
Compress on the fly
Responses with no precompressed variant — dynamic handler and proxy output —
can be compressed per request. Build with the compression feature and enable
it in the site’s config:
// site access/compression config
compression: ( enabled: true, min_size: 1024 ),
boatramp streams a gzip or brotli encoder over compressible responses at least
min_size bytes. It skips Set-Cookie responses for BREACH safety, and Range
requests always serve identity. Where a precompressed variant exists it still
wins — on-the-fly compression only fills the gap.
Back up & restore
boatramp keeps its state in a few well-defined places. Back up each one, and a restore is putting them back and re-verifying. There is no single dump command — you snapshot the backends you configured.
What to back up
| State | Where it lives | Back up |
|---|---|---|
| Blobs (file contents) | <data-dir>/blobs, or your S3/R2 bucket | The directory, or the bucket (versioning/replication). |
| Control-plane metadata (deployments, site config, tokens, cert records) | the KV: <data-dir>/kv-slate, or the object store SlateDB runs on | The KV store’s files/bucket. |
| Per-node Raft store (cluster) | each node’s store_dir | Each node separately; it is node-local, never shared. |
Secrets KEK (if secrets: local) | kek_file | The KEK. Without it, wrapped certificates are unrecoverable. |
| ACME certificate cache | --acme-cache (default <data-dir>/acme) | Optional — certificates re-issue, but backing it up avoids re-issuance and rate limits. |
Blobs are content-addressed and metadata references them by hash, so the two must be backed up as a consistent pair — back up the KV no earlier than the blobs so every referenced blob exists.
Restore
- Restore the blob store, then the KV store.
- Restore the KEK if you use
secrets: local, so the control plane can unwrap cert keys. - In a cluster, restore each node’s own Raft store; do not copy one node’s store to another.
- Start the server.
- Verify blob integrity:
boatramp scrub
scrub: 512 blobs verified, 0 corrupt, 0 missing
scrub re-hashes every stored blob and confirms it still matches its key, so a
partial or corrupt restore is caught before it serves bad content. If it reports
missing blobs, the KV was restored ahead of the blob store — restore the blobs
and re-run.
Warning: losing the
secrets: localKEK makes envelope-wrapped certificate keys unrecoverable. Back the KEK up with your other secrets, separately from the data it protects. See Encrypt secrets at rest.
Garbage-collect & verify integrity
boatramp prune reclaims disk by deleting orphaned deployments and the blobs no
deployment references. boatramp scrub re-hashes every stored blob to confirm
its content still matches its key. Run prune to recover space; run scrub to catch
bit-rot, tampering, or unreadable blobs — for example after restoring a
backup.
Warning: prune deletes data. Deleted deployments and blobs are gone. Keep enough deployment history to roll back to, and preview with
--dry-runbefore you delete anything.
1. Preview what prune would delete
Run a read-only pass first. Nothing is deleted:
boatramp prune --dry-run
scanning 3 site(s), 4213 blob(s)…
my-site 12 deployment(s), keep 10, prune 2
other-site 5 deployment(s), keep 5, prune 0
would delete 2 orphaned deployment(s), 87 unreferenced blob(s) — 214 MiB
dry run: nothing deleted
2. Prune
Prune previews, asks for confirmation, then deletes. A grace window
(--grace, default 3600s) protects a just-uploaded, not-yet-activated deployment
from being collected mid-publish. Aliased deployments are retention-protected.
boatramp prune --keep-last 10 --keep-age 604800
prune 2 orphaned deployment(s), 87 unreferenced blob(s) — 214 MiB. proceed? [y/N] y
deleted 2 deployment(s), 87 blob(s) — reclaimed 214 MiB
--keep-last N— keep the N most recent deployments per site.--keep-age SECONDS— also keep anything activated within that age.--yes— skip the confirmation prompt (for cron).
Prune also reclaims orphaned content-addressed site-config bodies once no site points at them.
3. Scrub
boatramp scrub re-hashes every stored blob and reports any whose content no
longer matches its key, or that cannot be read. It is read-only:
boatramp scrub
4213 blob(s) verified, all intact
Scrub exits non-zero on any finding, so it fits a cron or health check. A failure names the offending key:
blob 9f86d081… corrupt: content hash mismatch
1 of 4213 blob(s) failed verification
Verification is offline by design: the serving path cannot re-hash a blob without buffering it whole, which would break streaming. Run scrub after restoring a backup to confirm every restored blob is intact before you serve traffic.
On-demand blob GC over the control plane
boatramp prune above is the node-local sweep (deployments + blobs) run against a
data directory. For reclaiming just the unreferenced blobs — the
content-addressed objects that no live deployment manifest references — over
the control plane (no shell access to the node), use boatramp blob purge --unreferenced. It is the everyday, on-demand front door to blob garbage
collection: nothing serving can break, because it only ever deletes blobs nothing
points at.
It is dry-run by default — report what would be reclaimed, delete nothing.
Add --apply to actually delete:
$ boatramp blob purge --unreferenced --server https://cp.acme.com # dry-run
blob purge: would reclaim 87 unreferenced blob(s) (214 MiB) — dry run
$ boatramp blob purge --unreferenced --apply --server https://cp.acme.com # reclaim
blob purge: reclaimed 87 unreferenced blob(s) (214 MiB)
- Gated at
System·Admin(the node operator) — a project admin, publisher, or deployer cannot reach it. --jsonemits the structured report.- Refused with a
409while a read-fallback secondary is attached ([serve.blob_fallback]): a unionlistover a primary-onlydeletewould otherwise reclaim a secondary-only object that a read could resurrect. Drain and drop the fallback first, restart the node, then purge — see Switch the blob backend with zero downtime.
--unreferencedreclaims blobs nothing references. Its siblingboatramp blob purge --drained-sourcereclaims a migration’s drained old backend (delete a source object only once it is byte-confirmed in the primary) — a different, migration-only mode covered in Switch the blob backend with zero downtime.
Switch the blob backend with zero downtime
boatramp stores every deployment’s content-addressed blobs, every hblob/…
ingress object, and the message-queue payloads (mqgp/…) in one blob
backend — the node’s [serve] blobs selector (fs, s3, gcs, azure).
Sooner or later you outgrow the one you started on: local disk to a cloud object
store, one provider to another, one region to another.
Don’t just flip --blobs. A bare backend switch points the serving path at
an empty store — the new backend holds none of the existing objects yet — so
every site 404s until you re-upload and re-apply everything. This how-to walks
the safe path instead: attach the old backend as a read-through fallback (no
serving gap), copy the data across, verify it, then reclaim the old store’s
space. The whole rollout is online.
The shape of the migration
- Configure the NEW backend as the primary and the OLD backend as a
read-only
[serve.blob_fallback]secondary. Restart. Serving now reads the new backend first and falls through to the old one on a miss — no gap. - Copy every object OLD → NEW. Idempotent, resumable, read-only on the source. Two ways to run it (offline vs daemon-mediated) — pick one.
- Verify + reclaim — on a verified copy the tool prints
SECONDARY FULLY DRAINED; then delete the old backend’s now-duplicated objects with a fail-closed purge. - Finish — remove
[serve].blob_fallbackand restart. The node is on the new backend alone.
1. Attach the old backend as a read-through fallback
Point [serve] at the new backend as usual, and add a
[serve.blob_fallback] block
describing the old one. It takes the same backend-descriptor shape as the
primary — a blobs selector plus the matching per-backend option fields.
serve: (
blobs: s3, // the NEW (primary) backend
s3_bucket: "acme-blobs-new",
s3_region: "auto",
blob_fallback: ( // the OLD backend, read-only
blobs: fs, // was local disk
secondary_timeout_secs: 5,
),
)
Restart the node. While the fallback is attached:
- Serving reads the primary first and falls through to the secondary only on a definitive miss of a boatramp-owned key. A transient primary error propagates — the node never serves stale bytes on a blip.
- The fall-through is prefix-allowlisted (content-addressed blobs,
hblob/,mqgp/), keys are forwarded byte-identical (tenant isolation is preserved), andput/deleteare primary-only — the secondary is never written. - The node logs a prominent transition-mode WARNING at startup, and blob GC refuses to prune (see Garbage-collect & verify integrity).
There is now no serving gap: a request for an object still only on the old backend is answered off the fallback while you copy.
2. Copy the data across
The copy is get → put per object, skipping any already present in the
destination at a matching size. Because content-addressed blobs are immutable, a
re-run after an interruption is a near-no-op — the copy is idempotent and
resumable, and it reads the source read-only (it never deletes). Choose the
runner that matches your access to the node:
Option A — offline (boatramp blob migrate, needs shell access)
A node-local command: it builds both backends in-process from node config
files (the boatramp.cfg [serve] blob block + optional [secrets] for a
sealed S3 credential), so it needs local access to the backends’ credentials —
not a BOATRAMP_SERVER. Because a bare blob migrate with a configured
blob_fallback defaults its source to that fallback and its destination to the
node’s own primary, the whole drain is one line:
$ boatramp blob migrate # source = [serve.blob_fallback], dest = primary
blob migrate: source = fs ./data/blobs
blob migrate: dest = s3 bucket=acme-blobs-new endpoint=(default) region=auto path_style=false
blob migrate complete: copied 1400 object(s), skipped 0 present, 5312880123 byte(s); VERIFY OK: 1400 object(s) present in destination
SECONDARY FULLY DRAINED — safe to remove [serve].blob_fallback and restart the node.
To copy between two arbitrary backends (e.g. a pre-boot volume-local copy, or provider→provider without a running node), name both sides explicitly:
$ boatramp blob migrate --from ./old.cfg --to ./new.cfg
Useful flags: --dry-run (probe reachability + classify, copy nothing),
--concurrency N (objects in flight, default 8), --prefix <p> (restrict to a
key prefix), --no-verify (skip the post-copy verification pass — on by
default), --json. An equal source == destination is refused.
Option B — daemon-mediated (boatramp blob drain, no SSH needed)
On a managed node reachable only over the control plane (a fly machine, a
Kubernetes pod — no fly ssh, no local disk), the offline command can’t run. Use
the daemon-mediated drain instead: the client triggers the running daemon
(which already holds both backends of its fallback composite open) to copy its
own configured secondary → primary internally, streaming progress back.
$ boatramp blob drain --server https://cp.acme.com
… (NDJSON progress on stderr) …
blob drain: SECONDARY FULLY DRAINED — safe to remove [serve].blob_fallback and restart the node.
The client names no source or destination — the daemon drains only its own
configured pair, which is a tighter authorization surface than the offline CLI’s
arbitrary --from/--to. It is gated at System·Admin (a project admin,
publisher, or deployer cannot reach it). No [serve.blob_fallback] configured ⇒
422. --dry-run / --concurrency / --prefix / --json pass through. Because
the copy is resumable, a dropped connection on a long drain is safe to re-run
(re-invoke and it resumes) — this sidesteps an edge idle-timeout.
3. Verify, then reclaim the old backend’s space
Both runners verify by default — after the copy they head every source
object in the destination and fail (non-zero exit) on any that is missing. A
verified drain prints the SECONDARY FULLY DRAINED signal shown above.
Once that verification passes, the old backend is holding a full duplicate of the data — pure cost. Reclaim it with a fail-closed purge that deletes only the source objects it can byte-confirm are present in the primary:
$ boatramp blob purge --drained-source --server https://cp.acme.com # dry-run
blob purge: would reclaim 1400 object(s) from the drained secondary (5.0 GiB) — dry run
$ boatramp blob purge --drained-source --apply --server https://cp.acme.com # do it
blob purge: reclaimed 1400 object(s) from the drained secondary (5.0 GiB)
--drained-source runs while the fallback is still attached — it decides
each key with a pure predicate (deletable iff the primary holds that exact key at
a matching size). An unconfirmed key survives — it is never deleted. Dry-run
is the default; add --apply to actually delete. --prefix restricts the sweep;
--json emits the structured report. System·Admin; a configured
[serve.blob_fallback] is required (else 422).
4. Finish the switch
Remove the [serve].blob_fallback block and restart. The node is now on the new
backend alone, the transition-mode warning is gone, and blob GC is re-enabled.
You can check the posture at any time — including from monitoring — without grepping the startup log:
$ boatramp blob status --server https://cp.acme.com
blob status: a read-fallback secondary is ATTACHED ([serve.blob_fallback]) — the node is mid-migration (TRANSITION mode). Drain it (blob drain / blob purge --drained-source), drop [serve.blob_fallback], and restart to finish.
$ boatramp blob status --json --server https://cp.acme.com
{ "blob_fallback_active": true }
blob status is read-only (System·Read); blob_fallback_active flips to
false once you drop the block and restart.
Worked example — local disk → Tigris (S3-compatible) with no SSH
The scenario that motivated this feature: a managed node on fly.io, ~1400
content-addressed objects on the machine’s local fs volume, moving to
Tigris (an S3-compatible store) — with zero
downtime and no shell access to the machine.
1. Point the primary at Tigris, keep fs as the fallback. The base S3
credential comes from the sealed
[serve.s3_credential] store
(seal it once with boatramp secrets set), so no secret is in the file:
serve: (
blobs: s3, // Tigris (S3-compatible)
s3_bucket: "acme-prod-blobs",
s3_endpoint: "https://fly.storage.tigris.dev",
s3_region: "auto",
s3_path_style: false,
s3_credential: (
access_key_id: "tid_public_akid",
secret_access_key: "boatramp:tigris-secret", // a sealed reference
),
blob_fallback: (
blobs: fs, // the OLD local-disk backend
secondary_timeout_secs: 5,
),
)
Deploy this config and restart. Serving is uninterrupted — reads that miss the (empty) Tigris bucket fall through to the fs volume.
2. Drain fs → Tigris over the control plane (dry-run first):
$ boatramp blob drain --dry-run --server https://cp.acme.com
blob drain: would copy 1400 object(s), skip 0 present — dry run
$ boatramp blob drain --server https://cp.acme.com
… NDJSON progress …
blob drain: SECONDARY FULLY DRAINED — safe to remove [serve].blob_fallback and restart the node.
3. Reclaim the fs volume, then finish:
$ boatramp blob purge --drained-source --server https://cp.acme.com # dry-run
$ boatramp blob purge --drained-source --apply --server https://cp.acme.com # reclaim
Then remove blob_fallback from the config and restart. The node runs on Tigris
alone — the entire migration happened online, without ever touching the machine’s
shell.
Not the same as routine GC
boatramp blob purge --drained-source is part of the migration flow — it
reclaims a drained secondary. The everyday, on-demand reclaim of blobs that
no live deployment references is boatramp blob purge --unreferenced, covered
in Garbage-collect & verify integrity.
(That mode is refused while a [serve.blob_fallback] is attached — drain and drop
the fallback first.)
See also
serve.blob_fallbackschemaserve.s3_credential(sealed base credential)boatramp blobCLI- Garbage-collect & verify integrity
Observe a running server
This page covers the four ways to watch a running boatramp server: the JSON
access log, the health endpoints, the Prometheus metrics endpoint, and the
per-site CLI (logs and stats). Each is one command or one endpoint away.
For the full metric list and the full set of access-log fields, see the metrics reference. This page covers only how to reach them.
Read the access log
Every request is logged on the boatramp::access tracing target. Set
BOATRAMP_LOG_FORMAT=json for a machine-readable sink, and start the server:
BOATRAMP_LOG_FORMAT=json boatramp serve
Each request writes one JSON object to stdout:
{"target":"boatramp::access","request_id":"1a2b3c-4","method":"GET","path":"/index.html","host":"my-site.example","client_ip":"203.0.113.7","status":200,"bytes":1841,"encoding":"br","cache_result":"full","duration_ms":3}
The request_id is assigned per request (an inbound X-Request-Id is honored, else generated),
and the same id tags the request’s captured guest log lines — so you can correlate a handler’s
output with its access line. The cache_result field is one of full, partial, not-modified,
redirect, or error. Verbosity follows RUST_LOG (default boatramp=info).
Pipe the sink to your log shipper, or to jq to read one field:
BOATRAMP_LOG_FORMAT=json boatramp serve | jq -r 'select(.target=="boatramp::access") | .status'
200
304
200
Check health
Two endpoints report health. Point a load balancer or orchestrator probe at them:
| Endpoint | Meaning |
|---|---|
/healthz | Liveness — the process is up. |
/readyz | Readiness — a cheap KV probe; returns 503 when the metadata backend is unreachable. |
Probe readiness — a 503 means the process is up but the metadata backend is
unreachable, so route no traffic to this node yet:
curl -i http://localhost:8080/readyz
HTTP/1.1 200 OK
ready
Scrape metrics
An admin-scoped Prometheus exporter is always served at /api/metrics, carrying
the process-wide serving and lifecycle counters. With the handlers feature it
also renders per-handler invocation counters and per-consumer queue-depth and
dead-letter gauges. Scrape it:
curl http://localhost:8080/api/metrics
# HELP boatramp_http_requests_total requests by status class and cache result
# TYPE boatramp_http_requests_total counter
boatramp_http_requests_total{status_class="2xx",cache_result="full"} 1420
boatramp_http_requests_total{status_class="3xx",cache_result="not-modified"} 87
boatramp_deployments_total 12
boatramp_activations_total 9
For every metric, its labels, and their meaning, see the metrics reference.
Tail guest logs and read handler stats
For sites running handlers, two commands report per-site activity. Tail the
captured guest stdout, stderr, and wasi:logging messages, with --follow to
stream new lines:
boatramp logs my-site --follow
2026-07-09T12:04:11Z my-site http/GET/api/hello stdout handling request id=7f3a
2026-07-09T12:04:19Z my-site queue/emails stderr retry 1: upstream timeout
Tail a function’s logs
A standalone function — a GraphQL subgraph, an auth function, or a worker
invoked through emit::invoke rather than served under a site — captures its guest
output the same way, but under its own scope. Tail it with --function (since
0.3.17); the flag takes precedence over --site / BOATRAMP_SITE, and --project
scopes it to a non-default project:
boatramp logs --function my-worker --follow
The logs read from GET /api/functions/<name>/_boatramp/logs (and /stream when
--follow is set) — the same captured-log store as the per-site endpoint, under the
function’s project-qualified scope, so a project-scoped token reaches only its own
functions’ logs.
Read invocation counts, consumer lag, and dead-letter totals:
boatramp stats my-site
site my-site
http/GET/api/hello invocations 1420 errors 3
queue/emails invocations 512 errors 1 lag 0 dead-letters 2
Messages that exhaust their retry budget are dead-lettered — kept with their
payload and counted here. Inspect the cause in logs, then redrive or purge
them; see Run consumers, crons, and streams.
On a cluster, the stats JSON also carries an async_shards block: for each function this node
drains, its owning_node and queued depth, plus a safetynet_only_drains counter. Use it to
answer “which node drains this function?” and to tell a shard gap (a function briefly owned by no
node) from a slow drain: a rising safetynet_only_drains means the unsharded backstop is covering
work the owner isn’t picking up (work is still draining), whereas a climbing queued with a flat
counter is a genuinely stalled drain. The messaging_safetynet_interval_ms knob (which paces the
delivery backstop) also bounds how quickly that async no-owner gap is recovered.
Captured guest lines are also mirrored to the server log under the boatramp::guest target (at
debug), so RUST_LOG=boatramp=debug surfaces guest output in serve.log too — handy in
development. Each captured line carries the request’s request_id (above). A site that opts out
with disable_log_capture captures nothing — its guest
stdio is discarded, useful when output may carry secrets.
Reference
- Full metric and access-log-field tables: Metrics reference.
Drive boatramp from an AI agent (MCP)
boatramp ships a Model Context Protocol server, so an agent like Claude (Desktop, Code) or Codex can operate your control plane in natural language: list sites, inspect deployments, activate or roll back, manage domains and aliases, tail logs, invoke functions, and inspect the cluster. One agent can drive several boatramp instances — each registered by name.
The server is the same binary you already run. It offers two transports, both
built into the default binary (the mcp feature):
- stdio — the
boatramp mcpsubcommand a desktop agent spawns. Can drive many named instances from~/.config/boatramp/mcp.toml. - HTTP — a
/mcpendpoint served byboatramp serveitself, for driving that node over the network. On by default; see Over HTTP below.
Both expose the same, complete, enumerated tool set — one named tool per control-plane operation (no generic passthrough), so every call is legible in an audit log and bounded by the token’s scope.
Register your instances
Each instance the agent can reach is a [[instances]] block in
~/.config/boatramp/mcp.toml. Add one with mcp setup add — secrets are stored as
specs (env:VAR, path:/file, or a literal), never resolved into the file:
$ boatramp mcp setup add prod \
--server https://boatramp.example.com \
--token env:BOATRAMP_TOKEN
added instance 'prod' -> https://boatramp.example.com
$ boatramp mcp setup add lab \
--server https://10.0.0.5:8080 \
--token path:/etc/boatramp/lab.token \
--insecure
Flags:
| Flag | Meaning |
|---|---|
--server <url> | The control-plane base URL (required). |
--token <spec> | Admin token: env:VAR, path:/file, or a literal. Omit for an unauthenticated/dev plane. |
--holder-key <spec> | The token’s cnf holder private key, for per-request DPoP/PoP proofs (see PoP-bind a token). |
--server-pubkey <hex> | Pin the server’s raw public key (RFC 7250 --tls rpk); see bootstrap TLS. |
--insecure | Skip TLS verification (self-signed cert on a trusted private network only). |
List and remove them:
$ boatramp mcp setup list
registered instances (~/.config/boatramp/mcp.toml):
prod -> https://boatramp.example.com (token)
lab -> https://10.0.0.5:8080 (token, insecure-tls)
$ boatramp mcp setup remove lab
Connect an agent (stdio)
Point your agent at boatramp mcp (or boatramp mcp serve). For Claude
Desktop, add to claude_desktop_config.json:
{
"mcpServers": {
"boatramp": {
"command": "boatramp",
"args": ["mcp"],
"env": { "BOATRAMP_TOKEN": "<your admin token>" }
}
}
}
For Claude Code:
$ claude mcp add boatramp -- boatramp mcp
The token env vars your instance specs reference (env:BOATRAMP_TOKEN above) must
be present in the process the agent spawns — set them in the env block (Claude
Desktop) or your shell (Claude Code).
Over HTTP
boatramp serve also serves the MCP protocol at POST /mcp (streamable-http),
so an agent can drive that node over the network without spawning the CLI. It’s
on by default whenever the control-plane API is served.
Point an HTTP-capable MCP client at https://<your-node>/mcp with an
Authorization: Bearer <token> header — for Claude Code:
$ claude mcp add --transport http boatramp https://boatramp.example.com/mcp \
--header "Authorization: Bearer $BOATRAMP_TOKEN"
How it authenticates (this is the important part):
- Opening the channel requires a valid token. No token, or an invalid one, and
/mcpanswers401— it’s gated exactly like the rest of the control plane. - Each tool call runs with your token’s authority. The endpoint forwards your
bearer to the node’s own control-plane API in-process for every operation, so
authorization is re-checked per call against your token’s scope. Give the agent a
least-privilege token and the write/destructive tools simply
403— the HTTP endpoint grants nothing the token doesn’t already grant. Nothing is minted, so it works even on verify-only nodes that hold no signing key. - Use a plain bearer, not a
cnf/DPoP token. A holder-bound token can’t be re-proven for the in-process calls (the node has no holder key)./mcprejects one at the door with a clear error rather than letting every tool call fail an opaque proof check; DPoP-bound setups use the stdio transport, which holds the holder key and signs each call. - Kill-switch.
/mcpis on by default, but you can turn it off fleet-wide with no restart:boatramp config set mcp.enabled false(it then answers404); set it back totrueto restore. A fast lever if you need to shut the surface off.
Reaching
/mcpremotely requires configuring the node’s origin. As an anti-DNS-rebinding defence,/mcpaccepts a request only if itsHostheader is loopback (localhost/127.0.0.1/::1) or the node’s configured canonical origin ([serve] pop_origin— the same origin you set for DPoP). A co-located agent (e.g. Claude Code on the same host) works out of the box; for a remote agent, setpop_originto the public URL you serve on. The allowlist is never emptied, so the rebinding defence stays on.
Using it
Ask the agent naturally: “list the sites on prod”, “what’s the current deployment for docs?”, “roll docs back to the previous deployment”, “tail the last 50 log lines for the api site”, “invoke the resize-image function with this payload”.
When more than one instance is registered, name it (“on lab, …”); with a single
instance the agent can omit it. list_instances shows what’s available.
Tools
The tool set is a complete, enumerated mirror of the control-plane API — one
named tool per operation, with no generic passthrough, so every call is legible in
an audit log. It spans sites + deployments (list_sites, get_site_config /
put_site_config, list_deployments / current_deployment / get_deployment,
activate_deployment, delete_site), aliases + domains (list_aliases /
set_alias / remove_alias, list_domains / start_domain_verification /
check_domain_verification / remove_domain), functions + workflows
(list_functions / invoke_function / function_usage / list_triggers /
rollback_function / set_function_alias, list_workflows / get_workflow /
define_workflow / delete_workflow / start_workflow_run), observability
(tail_logs, handler_stats, operate_dlq), fleet + config (cluster_members /
promote_member / revoke_member / rotate_mesh_key / create_join_token,
get_daemon_config / set_daemon_config / rollback_daemon_config,
invalidate_cache, list_compute, cert_status, prune_report, scrub_blobs),
and identity (mint_token, revoke_token, whoami).
Authorization is the token’s, not the agent’s. Every call carries the caller’s token, so the agent can do exactly what that token is scoped to — no more. Give the agent a least-privilege token (see make a scoped token); a read-only token makes the write and fleet-admin tools
403. The write, delete, token, and cluster tools can be destructive (overwrite config, delete sites/aliases/domains, mint/revoke tokens, change cluster membership) — scope accordingly.
Manage certificates in a cluster
In a cluster the leader owns TLS. It issues each certificate once, stores it in the replicated control plane, and every node serves that replicated cert and hot-swaps it on renewal. You configure ACME on the cluster, not on each node.
For single-node issuance, see Get an automatic certificate. To stand a cluster up first, see Deploy a self-hosted cluster.
How cluster certs work
- One writer. The leader runs the ACME account and drives the DNS-01 / HTTP challenge, so competing nodes never race to answer the same challenge or double-register an account.
- Replicated storage. An issued certificate commits to the Raft log like any other control-plane write. Every voter and learner applies it and holds the same cert.
- Local serving. Each node serves TLS from its own applied copy. A node that joins later replicates the existing certs before it accepts traffic.
- Hot-swap on renewal. When the leader renews, the new cert replicates and each node swaps it in on the next handshake. Live connections stay up and you restart nothing.
Set the ACME options in boatramp.cfg once and apply the same config to every
node. Do not point individual nodes at their own file-cache certs.
List managed certificates
boatramp cert-status reads the replicated store and prints each managed
certificate with its domain and days to expiry. It never prints key material:
boatramp cert-status --server https://10.0.0.1:8080
example.com (74d left)
www.example.com (74d left)
api.example.com (12d left)
The --server flag (or the BOATRAMP_SERVER environment variable) points at any
node; every node returns the same replicated list. A certificate past its expiry
shows (EXPIRED) instead of a day count. When the control plane holds no managed
certificates, the command prints no cluster-managed certificates — you also see
this on a single node using a local file cache (--tls acme), which is not
cluster-managed.
Renewal
Renewal is automatic. The leader tracks each certificate’s expiry, renews ahead
of time, and replicates the result. Run cert-status to watch the day count
reset after a renewal; you do not renew by hand and you do not restart nodes.
If the day count stops falling near expiry, check that the leader reaches the ACME provider and that the challenge still resolves — the same credentials you set for ACME issuance.
Deploy a single node in production
One process, local disk, authenticated control plane, TLS. Blobs go to the filesystem; control-plane metadata goes to an embedded SlateDB that is durable on every write. This is the whole platform on one host.
For when to move beyond one node, see Deployment topologies.
1. Generate a root key and set up auth
The control plane authenticates every management request. Generate a root key once:
boatramp auth init
BOATRAMP_AUTH_ROOT_PRIVATE_KEY=es256:6f2c…
BOATRAMP_AUTH_ROOT_PUBLIC_KEY=es256:03a1…
Keep the private key in the server’s environment (or a secrets manager). Full flow — including minting your first admin token — is in Bootstrap authentication.
Warning: under the default
multi-tenantsecurity posture,serverefuses to start on a non-loopback address with no root key. That is deliberate: a public bind with auth off exposes the control plane. Configure a key (below), or bind127.0.0.1, or select a looser posture for local use — see Choose a security posture.
2. Run the server
boatramp serve \
--addr 0.0.0.0:8080 \
--data-dir /var/lib/boatramp \
--auth-root-private-key "$BOATRAMP_AUTH_ROOT_PRIVATE_KEY"
control-plane auth enabled (issuer)
serving http://0.0.0.0:8080 — data /var/lib/boatramp
Blobs land under <data-dir>/blobs and the KV under <data-dir>/kv-slate. A
write-through in-memory cache fronts hot metadata, so an activate is visible
immediately.
Prefer a config file for anything non-trivial: put the same settings in
boatramp.cfg and run boatramp serve --config boatramp.cfg. Flags and
environment variables override the file. See the
boatramp.cfg schema.
3. Add TLS
Terminate TLS at boatramp with an automatic certificate:
boatramp serve --config boatramp.cfg \
--tls acme --acme-domain pad.example.com \
--http-redirect-addr 0.0.0.0:80
--http-redirect-addr opens a second listener that answers plain HTTP with a
308 to HTTPS. For wildcard certificates, custom certificates, and the DNS-01
flow, see Get an automatic certificate.
To terminate TLS at a reverse proxy instead, run --tls off, set the site’s
https_redirect, and list the proxy in the site’s trusted_proxies so
X-Forwarded-For and X-Forwarded-Proto are believed.
4. Choose the storage backends
--blobs and --kv select where data rests. The defaults (fs, slatedb)
suit a single node.
| Flag | Default | Alternatives |
|---|---|---|
--blobs | fs | s3 (S3 / MinIO / R2 — in the default build) |
--kv | slatedb | memory, cloudflare (in the default build) |
SlateDB runs over any object store, so a single node can keep its KV on S3/R2 as well. Full option list: boatramp.cfg schema.
Next steps
- Bootstrap authentication & mint tokens
- How a request reaches your site
- Attach a custom domain
- Back up & restore
- Observe: logs, metrics, health, stats
- Scale out: Deploy a self-hosted cluster
Deploy a self-hosted cluster
A cluster replicates the control plane with Raft. Writes go to the leader and
commit to a replicated log; every node serves reads from its local applied
state. It is the same binary and the same commands as a single node — clustering
is a cluster: section in boatramp.cfg, not a separate mode.
Use a cluster when you need highly available control-plane writes, or low-latency reads in more than one region. For the topology and its trade-offs, see Deployment topologies.
A cluster is defined by one root of trust — the control-plane root key. Every node knows only that anchor; there is no peer map. A new node generates its own mesh keypair on first boot, derives its own id from it, and joins by redeeming a single-use ticket — the seed admits it, and it learns the current members (each individually root-signed) from the join response. Growing the cluster is two commands and one paste.
Before you start
- The control-plane root key — a cluster is its root key. It signs join tokens, member assertions, and each node’s TLS attestation. Custody is your choice (a local key or an external KMS/HSM/Vault signer), at any posture, with no hard gate — see Mesh identity & the single root anchor.
- A shared blob backend (S3 / R2) so every node serves the same content, and — if
you use the
sqlhandler binding — a sharedsqld. Each node keeps its own Raft store on local disk.
Warning: never point two nodes at the same Raft
store_dir. Each node must have its own durable store; sharing one corrupts the log.
1. Found the first node (one command)
Found a brand-new cluster from one node. Founding is explicit and one-time —
you pass --cluster-init. A node never self-founds by accident (no state + no
seeds fails closed, never a silent second genesis).
(
serve: (
addr: "0.0.0.0:8080",
blobs: "s3",
kv: "slatedb",
auth_root_private_key: "es256:…", // the cluster's root of trust
),
cluster: (
listen: "0.0.0.0:7000", // the Raft peer mesh, distinct from serve.addr
store_dir: "/var/lib/boatramp/raft",
),
)
boatramp serve --config boatramp.cfg --cluster-init
The node generates its mesh identity, derives its id, and bootstraps a 1-node
cluster. No node_id, no voters, no bootstrap flag, no peers map.
2. Grow the cluster (two commands, one paste)
On the running node, mint a join ticket. It bundles a single-use token, the seed address the joiner should reach, and the root anchor the joiner verifies everything against:
# The root anchor is the public key of your serve.auth_root_private_key:
root_pub=$(boatramp auth pubkey --private-key "$BOATRAMP_AUTH_ROOT_PRIVATE_KEY")
boatramp cluster add --server https://10.0.0.1:8080 --root-pubkey "$root_pub"
brjoin1.eyJzZWVkcyI6WyJodHRwczovLzEwLjAuMC4xOjgwODAiXSwi…
single-use join ticket — hand it to exactly one new node, e.g.:
boatramp serve --cluster-join brjoin1.eyJz…
On the new node, paste the ticket. It has only its own config (bind address, store dir) — no peer map, no id:
boatramp serve --config boatramp.cfg --cluster-join brjoin1.eyJz…
The joiner:
- fetches the seed’s attestation and verifies it against the root anchor
(the same
auth pinflow), pinning the seed; - proves possession of its own mesh key (a signature the seed checks — a stolen token alone admits nothing);
- is admitted, added as a learner, and adopts each returned member only after verifying its root-signed assertion — a malicious or stale seed cannot inject a fabricated member.
Repeat cluster add → --cluster-join for each node. In Kubernetes the operator
does this for you (the ordinal-0 pod founds; the rest join).
3. Check membership
cluster status is address-primary — the address is the handle you use for
remove:
boatramp cluster status --server https://10.0.0.1:8080
ADDRESS ROLE NODE STATE
https://10.0.0.1:7000 leader 9f86d081 ready
https://10.0.0.2:7000 voter 3a7bd3e2 ready
https://10.0.0.3:7000 learner 1b4f0e98 lagging
Add --full for whole node ids.
4. Publish and verify replication
Publish to any node — writes forward to the leader — and read from another:
boatramp sync ./dist --site my-site --server https://10.0.0.1:8080
curl https://10.0.0.3:8080/_sites/my-site/ # by name from node-3's applied state
5. Remove a node
cluster remove takes the address shown by status (or a raw node id). It
deletes the node’s trust cluster-wide, drops it from the quorum, and leaves a
durable revocation tombstone — a fresh token cannot silently re-admit a
just-removed key without an explicit un-revoke.
boatramp cluster remove https://10.0.0.3:7000 --server https://10.0.0.1:8080
Restart & resume
A node that already has durable Raft state resumes from it on restart — it never re-founds and never re-joins. A former member whose volume was wiped must rejoin via a seed (it refuses to re-found), which closes the split-brain footgun.
Certificates in a cluster
The leader issues each certificate once and stores it in the replicated control plane; every node serves the replicated cert and hot-swaps it on renewal. See Manage certificates in a cluster.
Migrating the root key
Because a cluster is its root key, moving custody (local ⇄ KMS/HSM/Vault) is a first-class operation — see Migrate the root key.
Reference
- Full
cluster:schema: boatramp.cfg schema. - Mesh identity & blast radius: SECURITY-mesh-identity.
- Per-node Raft keys: KV keyspace.
Run boatramp on Kubernetes
boatramp ships a Kubernetes operator in the same binary — there is no separate
controller image or Helm chart to track. The operator reconciles a
BoatRampCluster custom resource into its workloads (a StatefulSet for cluster
mode, or a Deployment + HPA for a stateless frontend) and drives the Raft
membership as pods come and go, using the same dynamic-join
model as the CLI — the ordinal-0 pod founds, the rest join with a ticket.
Install the operator
The operator ships as a Helm chart (charts/boatramp-operator) — CRDs, a
least-privilege ClusterRole, and the operator Deployment:
helm install boatramp-operator ./charts/boatramp-operator \
--namespace boatramp-system --create-namespace
Or, without Helm, apply the same bundle emitted by the binary itself:
boatramp operator manifests | kubectl apply -f -
boatramp operator crds prints just the CRDs (the chart’s crds/ are generated
from these — a CI check guards against drift); boatramp operator run is the
controller entrypoint (what the Deployment runs). The operator watches
BoatRampCluster and the tenant Site CRD (and Function, once the FaaS backend
lands) and reconciles them via server-side apply, so it owns exactly the fields it
sets. Release images are cosign-signed with an attached CycloneDX SBOM.
Create a cluster
Provision the cluster’s keys as Secrets, then declare the cluster. The pods need
the root private key to sign join tokens/attestations (authSecret); the
operator needs an admin token to drive membership (adminTokenSecret):
# The auth Secret wired into the pods: the root private key (the founder signs
# with it) + a single-use bootstrap secret (to mint the first admin token).
kubectl create secret generic prod-auth \
--from-literal=root-private-key="$BOATRAMP_AUTH_ROOT_PRIVATE_KEY" \
--from-literal=bootstrap-secret="$(openssl rand -hex 16)"
# The admin token the operator uses for /api/cluster/* — mint it against the
# founded cluster with the bootstrap secret (`token bootstrap`), then store it:
kubectl create secret generic prod-admin --from-literal=token="$ADMIN_TOKEN"
apiVersion: boatramp.dev/v1alpha1
kind: BoatRampCluster
metadata:
name: prod
spec:
mode: cluster # or `stateless` (Deployment + HPA)
replicas: 3
storage: 10Gi # per-node Raft PVC (cluster mode)
posture: multi-tenant # the operator enforces this floor
rootPubkey: "es256:03a1…" # the cluster root anchor (auth pubkey)
authSecret: prod-auth # Secret: root-private-key (+ bootstrap-secret)
adminTokenSecret: prod-admin # Secret with an admin `token` key
The operator renders a [cluster] config into the pods (so serve runs the
embedded Raft node), runs each pod’s control plane over RPK-TLS (--tls rpk),
wires the root private key + bootstrap secret from authSecret, exposes the mesh
port on the headless Service, and gives each pod its own dialable advertise
address via the downward API — so the founder can sign, self-attest, and joiners
can be reached. Because the control plane is RPK-TLS (RFC 7250 raw public keys),
which the kubelet’s HTTP prober can’t speak, cluster-mode pods are probed with a
TCP-socket readiness check — a node binds its listener only after it has
founded/joined and is serving, so “port open” is the right readiness gate.
The reconciler:
- Applies the StatefulSet (+ headless Service, per-node PVC, PDB), a client Service, and a ConfigMap.
- Designates pod-0 as the founder — the pod reads its own name from the
downward API (
BOATRAMP_POD_NAME); ordinal 0 founds, every other ordinal joins. (The node identity is still derived from each pod’s mesh key.) - Reaches every pod’s control plane over an RPK-TLS channel pinned to that pod’s root-attested key — the same attestation-pin a joiner uses — so no membership call trusts an unauthenticated endpoint.
- Keeps a fresh single-use join ticket in the
<name>-joinSecret (which the pods read asBOATRAMP_CLUSTER_JOIN) while the cluster is below its desired size, so a booting joiner can self-join at startup; its redemption adds it as a Raft learner on the leader. - Drives one quorum-safe membership transition per reconcile against the cluster API — promote a caught-up learner to a voter (on the leader), or, on scale-down, remove an out-of-range member before its pod is deleted. It never acts without quorum and never removes the last voter.
Without adminTokenSecret/rootPubkey the operator still reconciles the
workloads and plans + reports membership, but does not execute it (both are
needed: the token to authenticate, the root pubkey to pin the pods’ RPK-TLS).
Observe
kubectl get boatrampcluster # PHASE + QUORUM print-columns
kubectl describe brc prod # .status.members + observedGeneration
boatramp cluster status --server <client-service-url> gives the same
address-primary membership view the CLI shows for a bare-metal cluster (the pod
address is the handle for cluster remove).
Declare sites with GitOps
A Site custom resource is reconciled into a boatramp site on its cluster’s
control plane — declare hostnames in Git, kubectl apply, and a finalizer
cleans up the routing on kubectl delete:
apiVersion: boatramp.dev/v1alpha1
kind: Site
metadata:
name: marketing
spec:
cluster: prod # omit ⇒ the sole cluster in the namespace
project: acme # omit ⇒ the reserved `default` project
domains:
- example.com # → primary
- www.example.com # → alias
- "*.preview.example.com" # → wildcard
The optional project field names the owning project (tenant boundary), so you
can drive multi-project deployments from Git; empty is the reserved default
project, byte-identical to the legacy per-site routing.
The operator resolves the target BoatRampCluster and PUTs the site config over
the same pinned RPK-TLS channel to the cluster’s pod-0 that the membership
executor uses (adminTokenSecret + rootPubkey), then reports .status.phase.
(kubectl get site shows it.) Publishing content to the site is still a
boatramp sync / CI deploy — the Site CR governs its identity + domains, not
its deployments.
Function(FaaS): theFunctionCRD is installed and watched, but its apply path awaits the FaaS backend (PLAN-faas); today it reports aPendingstatus. Don’t rely on it to deploy a component yet.
Scaling
Change spec.replicas and re-apply. The operator converges one member at a time:
scale-up adds learners then promotes them; scale-down removes the highest
ordinals first, always quorum-safe. Kill a pod and the StatefulSet recreates it;
it rejoins (or resumes from its PVC) with no manual step.
A node’s PVC is retained on scale-down and on StatefulSet delete
(persistentVolumeClaimRetentionPolicy: Retain) — a Raft voter’s durable
log/state is never auto-reclaimed. Removing the data is an explicit operator step.
Rolling upgrades
Bump spec.image and re-apply. The operator drives a quorum-aware rolling
upgrade: it pauses the StatefulSet rollout (via the RollingUpdate partition)
whenever the cluster lacks a spare ready voter, so an upgrade never drops the
cluster below quorum. Combined with the PodDisruptionBudget, a node drain behaves
the same way. When a voter’s pod does restart, Raft re-elects a new leader
automatically (a sub-second election); explicit leader-transfer to avoid that brief
write pause is a future optimization (openraft 0.9 has no simple transfer call).
spec reference
| Field | Type | Default | Description |
|---|---|---|---|
mode | cluster | stateless | cluster | Raft StatefulSet, or a stateless Deployment + HPA. |
replicas | integer | 1 | Desired node count. |
image | string | operator’s own image | Container image (an explicit version). |
storage | string | — | Per-node Raft PVC size (cluster mode). |
posture | string | — | Security posture floor; a tenant CRD can never relax it. |
adminTokenSecret | string | — | Secret (key token) with an admin control-plane token — enables the membership executor. |
rootPubkey | string | — | The cluster root anchor (alg:hex) a joining pod verifies against. |
authSecret | string | — | Secret wiring auth into the pods: root-private-key (the founder signs with it) + optional bootstrap-secret. |
See also
- Deploy a self-hosted cluster — the dynamic-join model the operator automates.
- Mesh identity & the single root anchor.
- Deployment topologies.
Migrate the root key
A cluster is its root key, so custody of that key (local ⇄ external KMS/HSM/Vault) is a first-class, low-friction operation — you never rebuild the cluster or hand-edit every node. There are two paths, depending on whether the target backend can import your existing key material.
For why custody matters and the blast radius it carries, see Mesh identity & the single root anchor.
Same-key custody move (zero re-pin)
If the target backend can import key material (AWS KMS import, Vault Transit import, GCP KMS), the public key — the anchor — is unchanged. Nothing re-pins and nothing re-signs: it is purely a custody change.
- Import your existing key into the external backend (per that backend’s docs).
- Re-point
[serve.signer]from the local key to the external backend — see Hold the signing key in a KMS/HSM/Vault for the backend config. - Restart. boatramp verifies the imported key yields the same public anchor and continues; every node still trusts the same root, so no join re-pins.
This is the reverse, too (external → local, e.g. offboarding a KMS): re-point
[serve.signer] back to the local key material.
New-key rotation (import-less HSMs)
When the backend cannot import (keys must be generated in-HSM), the anchor must rotate. boatramp keeps a replicated root-anchor set so both the old and new anchors are trusted during the overlap — no window where a node rejects a valid token — and no per-node edit:
# 1. Mint the new anchor in the target backend, then trust it cluster-wide:
boatramp auth rotate-root --add "$(boatramp auth pubkey --private-key "$NEW_KEY")"
# 2. Re-point [serve.signer] / BOATRAMP_AUTH_ROOT_PRIVATE_KEY to the new key so
# new tokens + node attestations are signed by it, and restart each node.
# 3. Once every node has converged (old + new both trusted), retire the old key:
boatramp auth rotate-root --retire "$OLD_PUBKEY"
auth rotate-root with no flag lists the currently-trusted extra anchors. Every
node verifies a token against its primary root and the replicated anchor set,
so old-key tokens keep working until you retire the old anchor in step 3. The
reverse rotation (new → old) is the same two commands.
See also
- Mesh identity & the single root anchor
- Hold the signing key in a KMS/HSM/Vault
- Deploy a self-hosted cluster
Deploy on Cloudflare Containers
boatramp runs on Cloudflare as its own cluster mode: the boatramp binary runs in Cloudflare Containers, and a thin edge Worker routes to it. The Worker reuses the same routing engine as the origin, so the edge and the Containers do not drift. This is the same binary and the same commands as a self-hosted cluster — Cloudflare is a deploy target, not a fork. For why the edge runs Wasm and why there is no separate coordinator, see Deployment topologies.
The deploy is native: boatramp cloudflare drives the Cloudflare REST API
directly — ensuring the R2/D1 resources, uploading the edge Worker, and creating
the container application. There is no wrangler, and nothing is generated for
you to run by hand — the same one-token, env-provided model as the S3/GCS/Azure
backends.
Before you start
CLOUDFLARE_ACCOUNT_IDandCLOUDFLARE_API_TOKENin your environment. The token needs the Workers Scripts, Containers, R2, and D1 scopes (plus DNS for a custom domain). boatramp never sees your token except through the environment.- Docker, to build the container image.
- A Cloudflare account with the Workers paid plan (Containers require it).
1. Build + push the container image
Build the image the Containers run and push it to a registry Cloudflare can pull from (its managed registry, or Docker Hub / ECR / GAR):
docker build -t registry.example.com/boatramp:v1 .
docker push registry.example.com/boatramp:v1
v1: digest: sha256:… size: 1573
2. Deploy
Preview the plan first (--dry-run mutates nothing) — it prints the resources,
image, edge-Worker metadata, and container application it will apply:
boatramp cloudflare \
--region enam --primary enam --quorum 1 \
--image registry.example.com/boatramp:v1 \
--r2-bucket boatramp-blobs --d1 boatramp-sql \
--dry-run
Then drop --dry-run to apply. boatramp cloudflare ensures the R2 bucket + D1
database (idempotent), uploads the edge Worker (creating its Durable Object
namespaces), and creates the container application referencing your image (a
Durable-Object-backed, scale-to-zero app needs no separate rollout — the next
request provisions an instance from the active version):
boatramp cloudflare \
--region enam --primary enam --quorum 1 \
--image registry.example.com/boatramp:v1 \
--r2-bucket boatramp-blobs --d1 boatramp-sql
cloudflare: account reachable; container API responsive
cloudflare: ensured R2 bucket "boatramp-blobs" + D1 database "boatramp-sql" (…)
cloudflare: uploaded edge Worker "boatramp"
cloudflare: creating container application "boatramp"
cloudflare: container application "boatramp" at version 1 (standard tier); an instance provisions on the first request
cloudflare: native deploy complete — boatramp running on CF Containers
The container is scale-to-zero: no instance runs until the first request, which provisions one (a cold start pulls the image + boots — up to ~2 minutes; the edge Worker rides it out and retries). Subsequent requests reuse the warm instance.
On Cloudflare, boatramp runs as a single durable instance — deploy with
--quorum 1 and one --region. A multi-node Raft quorum is not possible on
the platform: CF Containers scale to zero and have no container-to-container
networking (every request is mediated by the container’s Durable Object), so a
majority of voting peers can’t stay simultaneously running and exchange the
low-latency RPCs consensus needs. Instead, the single instance keeps all state
durably in R2 (see below), which is Cloudflare’s architecture for this — a
parked or replaced container restores its state from R2. (Multi-node Raft targets
self-hosted / VM / orchestrator deployments with real peer networking; to inspect
what such a topology’s reference artifacts look like, add --emit-artifacts ./cloudflare — those are not a Cloudflare deploy.)
Control-plane auth
The container binds a public port (behind the edge Worker), so boatramp requires
control-plane auth to be enabled. Set BOATRAMP_AUTH_ROOT_PRIVATE_KEY (from
boatramp auth init) before deploying — the deploy delivers it to the container so
public site routes stay open while /api/* requires a token. If you don’t set one,
the deploy generates and prints a key once; save it (mint tokens with it, and
reuse it to redeploy with the same root — Cloudflare can’t return it later). Mint an
admin token offline with the same key: boatramp token mint --role admin.
Durable state in R2. The deploy points the container at R2 for all durable state: blobs go to the R2 bucket over the S3 API, and the control-plane metadata (deploy manifests, the per-site
currentpointer) is a SlateDB store on the same bucket. So a scale-to-zero instance keeps everything across a stop — the in-image/datanow holds only ephemeral caches (the wasmtime compile cache). The R2 S3 credentials are derived from your API token (no separate token to provision, and the container never holds the raw Cloudflare token), so the token needs only its existing R2 scope.
3. Publish and verify
Point publishing at the deployed domain — it behaves like any boatramp server, and deploys persist across cold starts (state is durable in R2):
boatramp sync ./dist --site my-site --server https://example.com
curl https://example.com/healthz
ok
Reference
- Full
cluster:schema: boatramp.cfg schema. - The edge/origin split and its trade-offs: Deployment topologies.
Embed boatramp as a library
boatramp is normally a single binary (server + CLI), but the server is a
backend-agnostic library crate you can embed in your own Rust application:
mount its HTTP surface into an existing axum app, or run it as a managed
sub-service. You hand it storage; it gives you the publishing API and the public
serving of your sites, handlers, and functions.
This is the right tool when you want boatramp’s publish/serve plane inside another process — an existing service, a desktop app, a test harness, a custom control plane — rather than as a separate daemon.
What is (and isn’t) a library
boatramp-serveris the request-plane library. Its own crate doc puts it plainly: “The server is backend-agnostic: it is handed a [DeployStore] (blobs in anyStorage, metadata in anyKvStore).” The storage backends live inboatramp-storage, the domain types inboatramp-core— all published on crates.io.boatramp-nodeis the assembly library. It holds the batteries-included node wiring theboatramp servebinary used to inline: building the store (build_blobs/build_kv), the compute backends (build_compute), the handler runtime (build_handler_runtime), control-plane auth (configure_auth), and the node graph that ties them together —assemble(NodeInput) -> RunningNode. It depends on the concrete backend cratesboatramp-serverdeliberately avoids, so it is the batteries-included assembler you can embed or test in-process.- The
boatrampbinary is a thin shell. What is left in the binary is the environment, not the assembly: parsingproject.cfg/boatramp.cfg, the store-migration guard, SIGHUP/signal handling, transport + TLS/ACME dispatch, cluster bring-up, and the web console. So embedding gives you the server and the assembly; you supply the environment you want. A basic embedded server is a few lines; a faithful batteries-included node is aboatramp_node::assemblecall.
The published library crates are pre-1.0 (0.2.x); the API may change between minor versions.
Fidelity: what embedding does and doesn’t cover
Which surface you embed decides how much of the real node you exercise:
router()alone runs boatramp’s library request handling but skips the assembly — how config becomes a store + compute backends + reconcile loops before the router exists. That assembly is exactly where integration bugs live: a site’s config applied at the wrong point in activation, posture gating of shared-kernel compute, the default-project materialization. Arouter()-only harness sails past all of them.boatramp_node::assemblecloses most of that gap: it is the serve binary’s node-graph wiring (store → handler runtime → deploy store → compute + reconcile loops → a router-ready node), so an in-process test drives the same assembly the operator runs.boatramp-nodeships exactly such a fidelity test. This is the surface to embed — and to test against — when you want the real node.
What neither exercises, and what therefore still needs the real artifact:
the CLI / project.cfg / boatramp.cfg parsing, the store-migration guard,
transport + TLS/ACME, cluster bring-up, and packaging. Validating those still means
driving boatramp serve (or the container image) over HTTP and the CLI against real
backends — which is what the crate’s live/e2e tests and the release boot gate do.
The compute backends are more embeddable than they look, and it’s worth being precise about what each needs:
- The docker backend does no process re-exec — it talks to a
dockerdover the Engine API.assembleregisters it whenever a daemon answers, so an in-process harness can drive real docker-backed compute (e.g. Postgres-as-OCI for a handlersqlbinding) by embedding the serving plane and pointingDOCKER_HOSTat a daemon. Noboatramp servesubprocess. - The container + microVM backends do re-exec a per-workload worker
(
__sandbox/__vmm-run/__vz-run) — and they re-execNodeInput::worker_exe(default: this process’s own executable). An embedding harness whose binary doesn’t implement those subcommands setsworker_exeto a builtboatrampbinary, and then those backends work in-process too: the serving plane stays embedded, and only each workload’s worker re-execs the realboatramp(exactly whatboatramp servedoes). They still need their substrate — root + cgroup v2 forcontainer,/dev/kvmfor the KVM VMM, macOS + Virtualization.framework forvmm-vz.
The one thing that is irreducible: a compute workload is a separate process — a
container or a VM — so a real Postgres never runs inside the test process itself.
What assemble (+ worker_exe) lets you collapse is the serve / control / tenancy
plane into your test binary (no boatramp serve subprocess); the workload then runs
in its backend (a dockerd container, or a re-exec’d worker), not as a spawned
boatramp serve. So assemble is a high-fidelity harness for the assembly + serving
plane and a viable driver for the compute backends — with the workload process being
the only part that stays out-of-process by nature.
1. Add the dependencies
[dependencies]
# The lean static server (no wasm handler engine by default — see step 5).
boatramp-server = "0.2"
boatramp-core = "0.2"
# Concrete backends: filesystem blobs; SlateDB is the default embedded KV.
boatramp-storage = { version = "0.2", features = ["fs"] }
axum = "0.8"
tokio = { version = "1", features = ["full"] }
The three moving parts you provide:
| Piece | Trait | This example uses |
|---|---|---|
| Blob storage | boatramp_core::Storage | boatramp_storage::FsStorage (a directory) |
| Control-plane metadata | boatramp_core::kv::KvStore | boatramp_core::kv::MemoryKv (ephemeral) |
| Handler engine (optional) | — | HandlerRuntime::disabled() (no wasm) |
2. Build a DeployStore
The DeployStore is boatramp’s control-plane handle over a Storage + a
KvStore:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use boatramp_core::deploy::DeployStore;
use boatramp_core::kv::MemoryKv;
use boatramp_storage::FsStorage;
let storage = Arc::new(FsStorage::new("/var/lib/myapp/blobs"));
let kv = Arc::new(MemoryKv::new());
let deploy = DeployStore::new(storage, kv);
}
MemoryKv is in-process and not durable — fine for a test or an ephemeral
embed. For production, swap in the durable embedded KV, boatramp_storage::SlateKv
(the slatedb feature, transactional and durable on every write — the same store
the single-node binary uses), and keep FsStorage (or S3/GCS/Azure) for blobs.
3a. Run it standalone
serve binds a listener and runs the whole server (publishing API + site
serving), including the background scheduler when the handler engine is present:
use boatramp_server::{serve, Auth, HandlerRuntime};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let storage = std::sync::Arc::new(boatramp_storage::FsStorage::new("./blobs"));
let kv = std::sync::Arc::new(boatramp_core::kv::MemoryKv::new());
let deploy = boatramp_core::deploy::DeployStore::new(storage, kv);
serve(
"127.0.0.1:8080".parse()?, // SocketAddr
deploy,
Auth::disabled(), // dev only — see step 4
HandlerRuntime::disabled(), // no wasm handlers — see step 5
)
.await?;
Ok(())
}
boatramp_server::shutdown_signal() is the graceful-shutdown future the standalone
path awaits; serve_with(.., ServerOptions) takes explicit request limits, CORS
allow-list, security posture, and PoP settings.
3b. Mount it into your own app
If you want to control the transport (your own listener, TLS, hyper config,
tower middleware, or extra routes), take the axum::Router directly instead:
#![allow(unused)]
fn main() {
use boatramp_server::{router, Auth, HandlerRuntime};
let app = router(deploy, Auth::disabled(), HandlerRuntime::disabled())
// compose your own middleware / observability:
.layer(tower_http::trace::TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>())
.await?;
}
router_with(.., ServerOptions) is the same with explicit options.
boatramp owns the root path space. It serves sites by host at
/and exposes the control plane under/api/…, somergeboatramp’s router with your own non-colliding root routes or wrap it in middleware — do notnestit under a path prefix (that breaks host-based serving and the absolute API paths). The connect-info make-service is what lets handlers see the peer address (IP rules, rate limiting, access logs).
3c. Assemble the full node (boatramp-node)
Steps 3a/3b give you the request plane over a bare DeployStore. To embed the
batteries-included node — the store plus the handler runtime, the compute
backends, and the background reconcile loops, wired exactly as boatramp serve
does — call boatramp_node::assemble. It is the same assembly the binary runs,
reachable as a library:
# The assembly crate. `fs` for filesystem blobs; `handlers` for the wasm engine.
boatramp-node = { version = "0.2", features = ["fs", "handlers"] }
#![allow(unused)]
fn main() {
use std::sync::Arc;
use boatramp_node::{assemble, NodeInput, RunningNode};
let storage = Arc::new(boatramp_storage::FsStorage::new("./blobs"));
let kv = Arc::new(boatramp_core::kv::MemoryKv::new());
let config = boatramp_node::config::ServerConfig::default(); // or parsed from boatramp.cfg
let options = boatramp_server::ServerOptions::default(); // posture, limits, PoP …
let RunningNode { deploy, handlers, auth, options, reconcile } = assemble(NodeInput {
config: &config,
data_dir: std::path::Path::new("./data"),
storage,
kv,
auth: boatramp_server::Auth::disabled(), // dev only — see step 4
options,
watch_provider: None, // cloud blob-change notifications, if any
provision_tier: Default::default(),
})
.await?;
// Hand the wired node to a transport — or `router_with(deploy, auth, handlers, options)`
// to mount it into your own app (step 3b).
boatramp_server::serve_with("127.0.0.1:8080".parse()?, deploy, auth, handlers, options).await?;
// `reconcile` holds the compute + domain-verify loops — keep it in scope while serving.
}
assemble materializes the reserved default project, builds the handler runtime
and any configured compute backends, and spawns the reconcile loops; you still
provide the environment the binary would otherwise resolve for you (parsing the
config, the migration guard, signals, the transport). This is also the surface an
in-process fidelity test should target — see the fidelity note above.
4. Authentication
Auth::disabled() leaves the control plane open — only acceptable for a private
test or a trusted in-process boundary. For anything reachable, build a real Auth
(root key + minted tokens, OIDC, or an external signer) exactly as the
auth bootstrap guide describes; auth.is_disabled() reports
which mode you’re in. Under a hardened security posture,
ServerOptions also carries the PoP/cnf enforcement knobs.
5. Add the WebAssembly handler engine (optional)
The default build is the lean static server — no wasmtime. To serve handlers,
functions, and their kv / sql / blobstore / messaging bindings, enable the
handlers feature and build a HandlerRuntime over an engine plus the same
backends:
boatramp-server = { version = "0.2", features = ["handlers"] }
#![allow(unused)]
fn main() {
// with the `handlers` feature:
let handlers = boatramp_server::HandlerRuntime::new(
engine, // boatramp_handlers::HandlerEngine
kv.clone(), // Arc<dyn KvStore> — wasi:keyvalue, per-site namespaced
storage.clone(), // Arc<dyn Storage> — wasi:blobstore, per-site namespaced
Some(sql), // per-site sql provider, or None to withhold the capability
Some(messaging), // wasi:messaging provider, or None
);
}
The guest namespaces are scoped per project/site by the server, so the same backends you pass here back every tenant safely.
Production checklist
- Durable backends:
SlateKv(or an external KV) for metadata;FsStorageor a cloud blob store for blobs.MemoryKvloses everything on restart. - Real auth (step 4) for any non-loopback surface.
serve_with/router_withto set upload/body limits, CORS, and the security posture rather than the permissive defaults.- Publish into it the same way the CLI does — over the HTTP publishing API
(
boatramp syncagainst your embedded server) — so you reuse the negotiated, content-addressed, atomic-activate flow. boatramp_node::assemble(step 3c) is the reference wiring for the full node (store + handlers + compute + reconcile). For the environment around it — cluster, TLS/ACME, the web console — the binary’sservepath (crates/boatramp/src/serve.rs) remains the reference.
What is boatramp?
boatramp is software you run to publish static sites, functions, and private services on your own infrastructure. A function is a portable WASI component you run behind a route (a handler), invoke by name, put on a schedule, or chain into a workflow — the same component, reached different ways (see Functions: the compute primitive). It ships as a single Rust binary that is both the server and the CLI: the same executable serves HTTP, exposes a control-plane API, and drives deployments from the command line. You install it, point it at a folder, and it hosts what you publish.
Two principles shape everything else.
Streaming-first. Every byte path streams. Uploads flow from the client straight into the backend, downloads flow from the backend straight to the client, and files are hashed in fixed-size chunks. No file is ever held whole in memory — on the client, the server, or in any backend.
Atomic, immutable deployments. Publishing writes a folder as a content-addressed, immutable deployment and flips the site to it in one atomic operation. Readers see the old deployment or the new one in full, never a half-written mix. Identical bytes are stored once, unchanged files are not re-uploaded, and rollback is re-activating an older deployment.
What boatramp is not
boatramp is not a hosted platform you rent. There is no account to sign up for and no bill tied to bandwidth or build minutes — you own the machine and the data. It is also not a CDN you point at an origin, and not a web server you hand a config file. Where Vercel and Netlify run the infrastructure for you, boatramp gives you the same publishing model to run yourself. Where Caddy and nginx serve files and proxy requests, boatramp adds deployments, virtualhost routing, TLS issuance, sandboxed functions, and authorization as one system.
Who it is for
Developers who want atomic deploys and instant rollback without a vendor, and operators who want one binary, one config format, and the same commands whether they run a single node, a Raft cluster, or Cloudflare Containers.
Where to go next
- Evaluating boatramp? Publish something in your first site.
- Running it in production? Start with deploying a single node.
- Writing dynamic routes? See the handlers tutorial.
- Want the compute model? Read Functions: the compute primitive.
- Want the deployment model? Read the core concepts.
Core concepts
boatramp is built on a small set of ideas. Understand these and the rest of the docs follow. This page explains the deployment model and the three configuration tiers; for exact fields, see the reference pages linked below.
Content is content-addressed
Every file boatramp serves is a blob — the raw bytes of one file, stored once and keyed by the SHA-256 of its contents. Because the key is the hash, identical bytes share a key across files, across sites, and across time. Two deployments that share an unchanged asset point at the same blob; no copy is made.
A deployment is an immutable manifest: a map from each site path to the
hash of the blob that answers it. The manifest names content by hash rather than
storing it, so a deployment is small, and once written it never changes. Routing
config authored in project.cfg is folded into the manifest, so it is versioned
and rolls back with the content it describes.
Publishing uploads only what is missing
When you publish, the client computes the manifest and asks the server which blobs it already holds. Only the missing blobs stream up; everything the server has seen before — from this site or any other — is skipped. A rebuild that touches one file uploads one blob.
Once the blobs are present, the server stores the new manifest and activates it by flipping the site’s current pointer in a single atomic step. A reader sees the previous deployment or the new one in full, never a half-written mix. Because every past manifest still exists and its blobs are still addressable, rollback is instant: activation points the site at an older manifest, with nothing to re-upload.
Aliases are named pointers
A site’s current pointer is one such reference; an alias is another. An alias
is a named pointer — staging, a per-branch preview — that resolves to a
specific deployment independently of the live pointer. You publish to an alias to
review a build, then activate it for the site when it is ready. Promotion is a
pointer move, not a rebuild.
Compute is a function
Dynamic code is a function — a portable WASI component plus the capabilities it
is granted. A function is reached through a trigger: an HTTP route (a
handler), a queue topic (a consumer), a schedule (a cron), or a call by name.
The component and its sandbox are the same in every case; only the door differs.
A site’s handlers, consumers, and crons are functions with triggers, and a
top-level function adds its own version line so it can be invoked, aliased, and
rolled back on its own. See Functions: the compute primitive.
A project owns sites, functions, and compute
Sites, functions, and compute workloads do not float free — each belongs to
exactly one project (a Workspace, in Uchron terms). A project is the owning
group above the site and the tenant boundary: the resources it holds are keyed
under its own namespace, scheduled independently, and a managed handler’s
row-level scope resolves to the owning project. Two projects can each own a blog
without collision — a site name is unique only within its project.
Membership is mandatory, but invisible until you need it. Every pre-project
resource belongs to the reserved default project, and omitting a project
targets default, so a single-project user’s URLs and behaviour are unchanged — a
lone site is simply a project of one. When you do want isolation, boatramp project create makes a new one, a global --project flag targets it, and Cedar
project_admin / project_publisher / project_viewer roles scope a token to it
so it cannot touch a sibling. See Organize sites into a project.
Three configuration tiers
Configuration is split by audience across three surfaces, so each concern lives where the right person controls it:
project.cfg— the per-site client config, authored beside your code and read bysync,build, andvalidate. It covers where and how to publish (including the owningproject), an optional build step, and deploy-scopedrouting. To declare a whole project — many sites plus its functions and compute — in one manifest, seeboatramp apply. Seeproject.cfg.boatramp.cfg— the server config, read byserve. It covers the bind address, storage backends, TLS, request limits, and anyclustersection. Seeboatramp.cfg.- Per-site config — domains, transport security, access control,
compression, and handler policy. This lives in the control-plane store, not
a file, so it travels with the server and is edited through the API and the
domainandaccesssubcommands.
The first two are RON files; the third is operator state. For every canonical term used here, see the glossary.
Functions: the compute primitive
Everything boatramp runs is a function: a portable WASI 0.2 component plus the capabilities it is granted. A function is the one artifact the engine executes. What differs between “a handler”, “a consumer”, “a cron”, and “an invoked function” is not the code — it is the trigger that reaches it.
This is the mental model to carry through the rest of the docs:
One primitive, two views. A function is the compute noun. A handler is a function reached by an HTTP route; a consumer is one reached by a queue topic; a cron is one reached by a timer; an invoked function is one reached by name. Same component, same sandbox, same bindings — different door.
You have almost certainly already written a function: a handler is one, viewed through a route. Nothing about that changes. The function framing just names the thing the route triggers, so the same component can also be invoked directly, put on a schedule, or wired into a workflow — without being rewritten.
Why a component, not a container image
A boatramp function is a standards-based WASI component, and that is the whole
point of the portability claim. The same .wasm runs unmodified on boatramp, on
another WASI 0.2 host (wasmtime, Spin, workerd), and — because the contract is
the component model, not a boatramp API — it is not locked to us. Instantiation is
sub-millisecond, the memory footprint is small, and the sandbox is strong: the
guest can only touch the host capabilities you grant — including wasi:keyvalue,
sql, wasi:blobstore, wasi:messaging, invoke (calling another function
in-process), graphql (run the project’s supergraph), session (a duplex
resumable channel), email (send through a project SMTP profile), capability
target-context(mint / read back a delegated capability),tenancy(present a tenant credential for the async lane), andadmin(self-configure the project). Reach for a function first.
Triggers: the many doors to one function
A trigger is a separate thing from the function it fires, and many triggers can point at the same function version. That is what lets one component be both a route and a cron:
| Trigger | The familiar name | What fires it |
|---|---|---|
| Route | handler | an HTTP request matching a host + path |
| Queue | consumer | a message on a topic |
| Timer | cron | a schedule |
| Invoke | (the FaaS verb) | a call by function name |
| Webhook | — | a signature-verified inbound POST |
| Stream | stream | host-native SSE / WebSocket fan-out (no component) |
A site’s handlers, consumers, crons, and streams in project.cfg are
functions with triggers — they desugar to exactly that, with no behavioural
change. You keep authoring them the familiar way; the engine runs one path.
Site-scoped vs. top-level functions
A function has an owner, and the owner sets how it is addressed and versioned:
- A site-scoped function is part of a site’s deployment. It versions and rolls
back atomically with the deploy (deploy-pinned), and it is the shape you get
from a
handlers/consumersentry. This is the default and needs no new concept — it is your handler. - A top-level function is owned by a project/tenant, not a single deploy. It
carries its own version line — deploy a new component version,
aliasa label likeprodat a version,rollbackindependently — and it is invoked by name. This is the FaaS surface: see Deploy & invoke a function.
Calling another function in-process
A function reaches a sibling by name, without leaving the sandbox for a network
round-trip, through the invoke capability. It is the same HTTP-shaped call the
platform uses to invoke a function from the outside — method, path, headers, body
in; status, headers, body back — but it dispatches on the same node, in-process, so
there is no re-authentication, no extra hop, and the call is metered and
rate-limited against the callee’s own quota exactly as an external invoke is.
Grant it like any other capability: the function must import invoke, and — because
letting a compromised function reach any internal function would be a real blast
radius — the operator names an allowlist of callable targets. Each entry may use
* wildcards, so one mechanism spans the whole range:
// project.cfg — a function that may call one family and one specific sibling
(
imports: ["invoke"],
invoke_targets: ["img-*", "audit-log"], // deny by default: empty ⇒ can call nothing
)
["*"] lets it call any sibling; ["resize"] exactly one. A call to a name outside
the list is refused (target-not-allowed) before the callee runs. The host also
caps the function-to-function call depth, so a cycle (A→B→A) is stopped with
loop-detected rather than nesting until the node is exhausted — the one guard that
makes reentrant invocation safe.
The same grant is available to a site handler (a routing.handlers route in
project.cfg), so a request handler reached over HTTP with the end user’s bearer can
also fan out to sibling functions — the mesh-orchestrator shape. Give the handler
imports: ["invoke"] and its own invoke_targets, gated by the site’s
allow_imports, exactly as for a function:
// project.cfg — a route handler that authenticates the user, then calls workers
routing: (
handlers: [
( route: "/agent/**", component: "orchestrator.wasm", methods: ["POST"],
imports: ["invoke"],
invoke_targets: ["tool-*"] ), // deny by default: empty ⇒ can call nothing
],
),
A handler is the root of a call chain (depth 0), and — unlike the platform’s external
function-invoke path, which stamps the control-plane token — an in-process invoke
passes the caller’s request headers through verbatim, so the user’s Authorization
reaches the callee unchanged (no forwarding to wire up by hand; see
handler bindings).
Reach for invoke to compose functions directly (a thin API function fanning out to
workers); reach for a workflow when you want declarative
orchestration with retries, compensation, and durable state.
The runtime is a knob, not a different thing
The three isolation substrates — an in-process Wasm sandbox,
a shared-kernel container, or a hardware-isolated microVM — are a per-function
runtime choice, not three different kinds of compute. wasm is the default
and scales to zero by instantiation; a function that needs to run an arbitrary
Linux program, or stronger isolation for untrusted code, selects microvm or
container. The trigger, the versioning, and the addressing are the same
whichever substrate runs it.
Where to go next
- Write one as a route: Write your first handler.
- Deploy and call one by name: Deploy & invoke a function.
- Chain several with retries and rollback: Orchestrate functions with workflows.
- Pick a runtime substrate: Compute: functions and their runtimes.
Architecture Overview
boatramp is a Rust workspace of feature-gated crates that compose into one binary:
| Crate | Responsibility |
|---|---|
boatramp-core | Domain types, the streaming Storage trait, the pluggable KvStore, content-addressed deploys, routing, config, access/WAF, messaging. No runtime/engine. |
boatramp-storage | Backends: FsStorage, S3/GCS/Azure blob, SlateDB + Cloudflare KV, libsql + external Postgres/MySQL SQL. |
boatramp-server | The axum HTTP server: serving pipeline, control-plane API, auth, limits. |
boatramp-handlers | The wasmtime engine + host bindings for Wasm components. |
boatramp-acme | ACME (incl. DNS-01) + the DnsProvider abstraction. |
boatramp-cluster | openraft integration: RaftKv, RaftMessaging, persistence, membership. |
boatramp-firecracker | The microVM compute backend: an embedded rust-vmm VMM and an external-Firecracker driver, with snapshot/restore. |
boatramp-container | The container compute backend: a jailed worker with namespaces, cgroups, and a seccomp filter. |
boatramp-docker | The remote-Docker compute backend. |
boatramp-cloudflare | The Cloudflare Containers compute backend + edge-Worker generator. |
boatramp | The CLI (serve, sync, domain, …) and deploy generators. |
The ComputeBackend trait, scheduler, and reconcile loop live in
boatramp-core::compute; each backend above is a separate, capability-detected
crate. See Compute: handlers vs containers vs microVMs.
Two kinds of data
boatramp keeps two very different things apart, so nothing is ever buffered whole in memory:
- Blobs — file contents — stream through a
Storagebackend (fs / S3 / R2), content-addressed by SHA-256. - Metadata — small, read on every request — lives in a
KvStore(deploy manifests, the per-site current pointer, site config, tokens, certs).
See Storage & KV and the KV Keyspace.
The request pipeline
One ordered pipeline, each stage driven by config:
- Host → site (virtualhost), with an optional default site.
- TLS / transport — HTTPS redirect + HSTS (proxy-aware via
X-Forwarded-Proto). - Access control — WAF → IP rules → rate limit → basic auth.
- Path normalization — clean URLs, trailing-slash policy, dot-segment collapsing (traversal-safe).
- Redirects, then handlers, then rewrites / SPA / reverse-proxy.
- Resolve to a manifest entry (directory index, custom error documents).
- HTTP correctness — conditional
304,Range/206, ETag, headers,Cache-Control, compression negotiation.
The routing logic (steps 4–7) is pure and lives in boatramp_core::route, so it
is unit-tested in isolation — and reused by the Cloudflare edge Worker, so the
edge and the origin route identically.
Deployment modes, one UX
The same commands and config run on a single node, a self-hosted Raft cluster,
or Cloudflare Containers. Environment differences hide behind the Storage /
KvStore / Messaging trait seams, not in the UX. See
Deployment topologies.
Storage & KV
boatramp stores blobs in a streaming Storage backend and all control-plane
metadata in a KvStore. The KvStore trait is deliberately tiny
(get/put/delete/list_prefix/write_batch), and it plays three roles.
One trait, three roles
| Role | Implementors | What it is |
|---|---|---|
| Storage (durable) | SlateKv (SlateDB over local FS / S3 / R2 / GCS), CloudflareKv, MemoryKv | Where the bytes rest. |
| Consensus frontend | RaftKv | Turns writes into replicated Raft entries; serves reads from local applied state. Persists its log + state to a Storage backend per node. |
| Caching decorator | CachedKv | A write-through LRU in front of any KvStore. |
They compose: CachedKv(SlateKv), or RaftKv over a per-node SlateKv.
Two topologies (pick one)
Consensus (RaftKv)
Writes go to the leader, commit to the replicated log, and apply to every node’s state machine; reads come from local applied state. This is multi-node cluster mode (self-hosted / VM / orchestrator). It does not apply to Cloudflare Containers, which run a single durable instance with a SlateDB store on R2 (a Raft quorum isn’t possible there — see Deploy on Cloudflare).
- Each node keeps its own durable Raft store — not shared (sharing a Raft log breaks Raft). Only blobs (S3/R2) are shared.
- No cache staleness, no
SIGHUP:RaftKvreads local applied state with no LRU in front.
Shared-store / no-consensus (CachedKv)
One backend is the source of truth and coherence is the store’s job. N stateless
frontends each front it with a local CachedKv; blobs are shared too.
- The shared store is itself replicated/consistent — Cloudflare KV, or a shared SlateDB-on-R2.
- A peer’s write isn’t visible until the local LRU evicts —
SIGHUP(or the changelog) forces the re-read. See Cache Coherence. - A single node on local disk is just this with one process; the cache never goes stale because nothing else writes.
SlateDB specifics
SlateDB is single-writer (manifest fencing). The shared-SlateDB topology is
therefore one writer process + read replicas (SlateKv::open_reader over
SlateDB’s DbReader), which serve reads and poll the manifest for new data;
control-plane writes funnel to the writer.
Selecting backends
--kv selects the storage; the frontend is consensus only if a [cluster]
config is present. --blobs selects the blob Storage:
fs(default) — the local filesystem (<data-dir>/blobs); watch-capable via inotify/FSEvents.s3— S3-compatible (AWS S3, MinIO, R2);--features s3.gcs— Google Cloud Storage (--gcs-bucket, ADC credentials);--features gcs.azure— Azure Blob Storage (--azure-account/--azure-container, shared-key auth);--features azure.
All of these backends are compiled into the default (batteries-included) build;
the --features names above are only needed for a --no-default-features build.
Every cloud backend streams reads and writes (never buffering a whole object) and
can back blob-change triggers
once its notification pipeline is provisioned. The per-site SQL binding (libsql: a
file per site, or a sqld namespace per site) is configured under
[handlers.bindings.sql]; a guest can also open an operator-configured external
Postgres/MySQL by name (bring-your-own, isolation the operator’s) — see
Bring your own database.
Cache Coherence
This concerns only the shared-store / no-consensus topology — N stateless
processes over one shared KvStore, each fronting it with a local CachedKv
LRU. The Raft topology needs none of it (replication keeps every node’s applied
state current; RaftKv has no LRU). A single process doesn’t either.
The goal: a process picks up another process’s control-plane write promptly and cheaply, scaling to thousands of sites — without TTL desync and without flushing the world on every write.
Why not the obvious options
- Per-entry TTL — every entry goes stale on its own clock; you tune a guess and live with desync.
- Flush-all on any write — one site’s edit flushes every process’s whole LRU
→ all frontends re-fetch their working set → a thundering herd on every write.
Cost scales with
cache_size × write_rate. Kept only as a rare backstop.
Targeted invalidation via a changelog
Invalidate only the changed keys (pop site X’s entries; leave the others hot). Cost is O(write rate), independent of site count; O(1) per change.
On a control-plane write, one entry _inval/{millis}-{writer}-{n} listing the
changed keys is appended to the shared store. Each process polls for entries
after its cursor, pops those keys from its LRU, and advances the cursor (its own
entries are skipped). Old entries are trimmed; a rare full flush is the gap
backstop. The feed is just KV data, so it works over Cloudflare KV or shared
SlateDB alike. Enable with --shared-cache-coherence.
For real-time (poll-free) delivery, a pusher (a Cloudflare Durable Object /
Queue, Redis, or ops) can POST /api/cache/invalidate {keys:[…]} directly.
Minimizing the surface: content-addressed config
The fewer mutable keys, the smaller the problem. SiteConfig is
content-addressed: an immutable siteconfig/<hash> body (caches forever, dedups
across sites) plus a tiny mutable site/<site> pointer. Only the pointer changes
on an edit, so the feed carries pointers, not config bodies — and the bodies
never need invalidation at all. (This also makes config edits atomic pointer
flips, like deploy activation.)
What is never cached
Coordination state — rate-limit windows (ratelimit/<site>/<ip>) and messaging
claim/lease state (mqp/…) — is read through the uncached backend; caching
it would yield stale leases / wrong counts in shared mode.
The request pipeline
Every request for served content runs through one ordered pipeline. Each stage is driven by the site’s config, and the stages run in a fixed order so the behavior is predictable. Nothing is buffered whole in memory — the response streams from the backend as soon as the pipeline resolves it.
The order
- Host → site. The
Hostheader selects the site (virtualhost routing), with an optional default site for an unmatched host. The full set of ways a request is matched to a site is in How a request reaches your site. - Transport. HTTPS redirect and HSTS, proxy-aware through
X-Forwarded-Protofrom a trusted proxy. - Access control. WAF, then IP rules, then rate limit, then basic auth — the first to reject wins. See Restrict visitor access.
- Path normalization. Clean URLs, the trailing-slash policy, and dot-segment collapsing (traversal-safe).
- Route. Redirects, then handlers, then rewrites / SPA fallback /
reverse-proxy. A redirect or rewrite may carry a
whencondition evaluated against the request (language, cookies, headers, file existence), which contributes to the responseVary. - Resolve. Map the path to a manifest entry — a directory index, or a custom error document when nothing matches.
- HTTP correctness. Conditional
304,Range/206,ETag, response headers,Cache-Control, and compression negotiation.
An early stage can end the request — a rejected access-control check, a redirect, a handler that answers — before the later stages run.
Inside handler dispatch
When stage 5 routes a request to a handler (a Wasm component) rather than static content, a small sub-pipeline runs around the component, in this order:
- Cookie session auth. If the site enables
cookie_authand the request carries the named cookie but noAuthorizationheader, boatramp injectsAuthorization: Bearer <cookie>here, before anything downstream — so the GraphQL edge, the data connector, the handler, and any siblinginvokeall see the same bearer. A cookie-authenticated request is CSRF-checked first. - GraphQL edge (if
graphqlis on). The query-guard rejects an over-deep/complex or disallowed operation before the handler runs; persisted-query/safelist resolution and — for a gateway site — federation planning + execution happen here instead of invoking a single component. - Response cache lookup (if
cacheis on). A cacheableGET/HEADhit is served without instantiating the handler. - Handler execution. The component runs with its granted host bindings.
- Response cache store. A cacheable response is stored after the bearer injection above, so its cache key already reflects the authenticated request and a private per-user response is not stored (see the caching rules).
Why the order is fixed
The order encodes precedence you would otherwise have to reason about per
request. Access control runs before any content work, so a blocked request never
touches the manifest. Redirects run before handlers, so a moved path does not
invoke code. Path normalization runs before routing, so route patterns match a
canonical path and cannot be bypassed with .. or a double slash.
The routing core is pure and shared
Stages 4 through 7 — normalization, routing, resolution, and HTTP correctness —
are pure functions in boatramp_core::route, with no I/O. That has two
consequences. They are unit-tested in isolation, against inputs rather than a
running server. And they are reused by the Cloudflare edge Worker, so a request
routes identically at the edge and at the origin — the two cannot drift, because
they run the same code. See the architecture overview.
How a request reaches your site
boatramp serves a site at a root mountpoint — the site’s files answer at /,
/assets/app.js, /api, exactly as they were authored. This page explains every
way a request is matched to a site, in the order you meet them: the local
single-site default, host/domain routing in production, the zero-DNS
<site>.localhost convenience, and the explicit by-name admin route.
The routing itself is one pure function shared by every deployment target, so a request resolves the same way on a single node, a cluster, or Cloudflare Containers. What differs is only which host names resolve to which site.
The single-site default (local first run)
When a server serves exactly one site, that site answers at the root of the
listener. Run boatramp serve, publish one site, and it is there:
curl http://127.0.0.1:8080/
No host header, no domain, no path prefix. This is the first-run experience in
Publish your first site: the site you just
published is the site at /. Publish a second site and the default turns off
(the server can no longer guess which one you mean) — then you address sites by
host, below.
Host / domain routing (production)
In production a site answers on a hostname you attach to it. The Host header of
each request selects the site; the request path is served at that host’s root. A
site can hold a primary hostname, exact aliases, and wildcards — see the
domains config.
boatramp domain add app.example.com --method dns
boatramp domain verify app.example.com
boatramp routes a host only after you prove you control it, so attaching is a
verify-then-route task — see Attach a custom domain.
Because selection rides the Host header, it behaves identically on every
topology; a domain is registered once and every node resolves it. A host that
matches no attached domain returns 404, unless a
default site or an explicit
--default-site catch-all is set.
<site>.localhost (zero-DNS local multi-site)
To work on several sites locally without editing DNS or /etc/hosts, address a
site by putting its name in the first host label. blog.localhost resolves to the
site named blog, served at root:
curl -H 'Host: blog.localhost' http://127.0.0.1:8080/
# or, so the browser/curl resolves it to loopback:
curl --resolve blog.localhost:8080:127.0.0.1 http://blog.localhost:8080/
Most resolvers (macOS, systemd-resolved) send *.localhost to loopback already,
so a browser can just visit http://blog.localhost:8080/. On systems that do not
(bare Windows, some musl setups), use --resolve or an explicit Host header —
that is a client resolver gap, not a difference in how boatramp behaves.
First-label routing never overrides a registered domain: an attached host always wins over a same-named label.
Note: the single-site default and
<site>.localhostrouting are conveniences for local and single-operator use. They are on for a loopback bind, and under thesingle-tenantanddevsecurity postures; they are off under the default strictmulti-tenantposture on a public address, where an unmatched host resolves only to an explicit--default-siteor404. This keeps a public multi-tenant server from ever resolvingHost: <sitename>.attacker.exampleto one of your sites by name.
/_sites/<name> (explicit by-name, admin/testing)
Every site is also reachable by name at /_sites/<name>/…, regardless of host.
This is an admin and testing affordance — a quick way to hit a specific site
without attaching a host:
curl http://127.0.0.1:8080/_sites/blog/
It is not a hosting model. Because the site’s content is served under a path
prefix, a site authored for root — with absolute references like /assets/app.js
or fetch('/api') — breaks here: those URLs resolve against the origin root, not
the /_sites/blog/ prefix. Use host routing (or the single-site default) to serve
such a site; reach for /_sites/<name> only for by-name inspection.
Sub-path mounts
Serving a site under a deliberate sub-path (for a site built with a matching
base path, e.g. a framework’s base / basePath) is not available yet. Absolute
URLs authored for root cannot be rewritten server-side in the general case, so the
supported model is a root mountpoint via host routing. See
Maturity, validation & support for status.
Choosing
| You want | Use |
|---|---|
| A quick local first run | The single-site default — publish one site, hit /. |
| Several sites locally, no DNS | <site>.localhost (first-label routing). |
| Production on your own hostname | Attach a domain; the site answers at its host’s root. |
| To inspect a specific site by name | /_sites/<name>/ (admin/testing). |
Authentication & authorization
The control-plane API — publishing, config, tokens — authenticates every request. Public serving never does. This page explains the model: how a credential is signed, how a request is authorized, and how a token can be narrowed offline. For the tasks, see Bootstrap authentication; for the right vocabulary, see RBAC roles, actions & resources.
Tokens are signed claim sets
A boatramp token is a COSE_Sign1 structure over a CWT claim set (RFC 8392 /
9052). The claims name the granted roles, an expiry, and a revocation id; the
whole thing is signed by the control plane’s root key. This has one property that
shapes the rest of the design: verifying a token needs only the public key.
There is no per-request database lookup — a node checks the signature and the
expiry against a public key it holds, decides the request, and moves on. Every
node can authorize independently, including read replicas that never mint
anything.
Revocation is the one piece that is not purely offline: a revoked token’s id is recorded, and the verify path rejects it. That check is a small keyed lookup, not a signature-scale cost.
Authorization is Cedar RBAC
Once a token verifies, the request is authorized with
Cedar. Cedar decides whether the token’s granted
roles carry a right — an action (read, write, deploy, admin) on a
resource (site, project, blobs, tokens, certs, cache, system),
optionally scoped to a target — that satisfies what the endpoint requires. The
policy is data: a default role-to-rights mapping ships built in, and an operator
can replace it (validated server-side, so a bad policy cannot brick the control
plane). Unmapped paths fall through to system · admin, so a narrow token
never reaches an ungated action by accident. The full vocabulary is in the
RBAC reference.
The project is the tenant boundary
Two resources are target-scoped. A site right binds to a <project>/<site>
target; a project right binds to a <project> and governs everything that
project owns — its functions, compute, and workflows, and the project entity
itself. This is what makes a project a hard tenant boundary: a token granted
project_admin:acme has full control of acme and every site under it, but Cedar
denies it any access to a sibling project shop. The built-in
project_admin / project_publisher / project_viewer roles express the common
tiers; a legacy site-only target (publisher:blog) is read as the default
project (publisher:default/blog), so pre-0.2.0 tokens keep working.
The same project identity is what a managed handler’s row-level scope resolves to. The tenant is asserted by the platform from the verified token and the routed host — never supplied by guest code — so a handler cannot read across into another project’s data. See Organize sites into a project.
The signing key can live outside the process
Because verification needs only the public key, the private signing key is used in exactly one place — minting — and can be held wherever you trust. boatramp resolves the public half at startup as the trust anchor and calls a signer to mint each token. The signer is a seam: a local key, a cloud KMS (AWS / GCP / Azure), HashiCorp Vault, or a PKCS#11 HSM. A verify-only node needs just the public key and cannot mint at all. See Hold the signing key in a KMS/HSM/Vault.
Delegation narrows a token offline
A token minted as delegatable carries a holder public key (a cnf claim). The
holder can attenuate it — sign a restrict-only block that adds caveats like
“one site only”, “read-only”, or an earlier expiry — with no server round-trip
and without the root key. Verification walks the chain: each block must be signed
by the previous block’s holder key, the caveats intersect, and the earliest
expiry wins. Because a block can only add restrictions, a delegated credential
can never widen authority beyond the original. Revoking the original by its id
revokes every credential delegated from it. This is how you hand a further-scoped
credential to a third party without minting a new token — see
Make a scoped CI deploy token.
Two planes: control-plane vs application identity
Everything above is the control plane — the operator credential that publishes, configures, and mints. A running handler has a second, entirely separate notion of identity: the application’s own end users. These never mix:
- A control-plane token (
COSE/CWT, above) authorizes/api/…and is verified against the root public key. It is boatramp’s. - An application bearer — whatever token your app’s users carry (an OIDC JWT, a session token) — is opaque to boatramp. The platform doesn’t mint or validate it as a control-plane credential; it forwards it to the handler, which verifies it with its own authorizer/OIDC config. The app owns its user identity.
boatramp only gives the application bearer structured meaning where you ask it to:
- The GraphQL data connector
can verify the bearer against your IdP (
claims_from_token: issuer + JWKS, signature/iss/expwith the algorithm pinned to the key) and bind a claim from it to a row filter — for multi-tenant SaaS isolation. A missing or invalid token contributes no claim, so the filter denies rather than widens, and an app claim can never override the host-assertedproject. - The federation gateway forwards the caller’s verified bearer to each subgraph (re-verified per subgraph — no escalation), so every subgraph enforces per-field authorization and row isolation on the real caller, not an anonymous gateway.
Sourcing the application bearer from a cookie
Normally the application bearer arrives in the Authorization header. A browser app
can instead keep it in an HttpOnly session cookie (out of JavaScript’s reach)
and opt the site into
cookie_auth:
when a request carries the named cookie but no Authorization header, boatramp reads
the cookie and injects it as Authorization: Bearer <value> at the edge, so it flows
to every consumer above exactly as a header bearer would (the header always wins).
boatramp only reads the cookie — your app issues, refreshes, and verifies it — and a
cookie-authenticated request is CSRF-checked against a configured origin allowlist.
Where auth does not apply
Public content serving is unauthenticated by design — a visitor fetching a page is not a control-plane principal. To restrict who may view a site, use per-site visitor access control, which is a separate mechanism from control-plane authorization.
Mesh identity & the single root anchor
A boatramp cluster is defined by one root of trust — the control-plane root key. This page explains what that key protects, the blast radius it carries, and the custody choices you have. It is an advisory, not a gate: boatramp does not force any particular custody on you.
What the root key does
The same root key underwrites everything a cluster trusts:
- Control-plane authorization — it signs the COSE/CWT tokens that authorize
/api/*operations. - Mesh admission — it signs the single-use join tokens and the root-signed member assertions a joiner verifies before trusting any peer.
- Node TLS identity — it signs each node’s bootstrap attestation, which a
joiner (or
auth pin) verifies to pin that node’s raw-public-key TLS identity.
A node knows only the root public key (the anchor). There is no peer map: a node’s own mesh keypair is generated on first boot, its id is derived from that key, and every trust decision keys on the full public key, never on the id.
Mesh private keys never leave a node
Each node generates and persists its own Ed25519 mesh identity (0600) and
only that node ever holds or mints its private key. The CLI and the
Kubernetes operator handle only the root key and tokens — never a node’s mesh
private key. Key rotation is node-local and make-before-break: a node rotates its
own key, trusts the new one cluster-wide, then retires the old — with no window
where a valid peer is rejected.
The blast radius (F8), stated plainly
Because the one root key now gates mesh admission as well as token authz, its blast radius is larger than a design with an independent per-node trust layer. If the root private key is compromised, an attacker can mint join tokens and member assertions — i.e. admit nodes to the mesh — in addition to authorizing control-plane operations.
boatramp surfaces this rather than hiding it: a cluster running on a local root key logs a one-line advisory at startup. That is the entire enforcement — there is no hard KMS/HSM requirement at any posture.
Custody is your choice, never gated
The root key may be:
- a local key (raw bytes in a
0600file), or - an external signer — AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault
Transit, or a PKCS#11 HSM. The
Signertrait is remote-capable, so signing (not just at-rest encryption) can live in the external backend and the private key need never enter process memory.
Both are valid at every security posture. Choosing an external signer narrows the blast radius (a compromised node cannot exfiltrate a key it never held), which is why it is recommended for multi-tenant or internet-facing clusters — but it is never imposed.
Narrowing it further, without imposing KMS
Two independent defenses reduce the blast radius without touching custody:
- A root-pubkey set.
cluster.root_pubkeysis a set, enabling make-before-break root rotation (add the new anchor, re-sign, retire the old) with no rejection window — see Migrate the root key. - A distinct mesh-admission signer. You can mint join tokens (and member
assertions) with a separate key from the admin-token root and trust it via
auth rotate-root --add <admission-pubkey>. The join path verifies against the admin root and the anchor set, so admission is authorized by the distinct key while the admin-token root stays independent — compromise of one does not grant the other. This narrows the radius without a separate signer config or forcing anyone onto an HSM. (Put the admission pubkey in the join ticket’s anchors so joiners verify members against it.)
Seeds are integrity-relevant (F2)
A seed’s attestation proves it is a fleet member under the root anchor — it
does not prove the seed is live, non-revoked, or the partition you intend. So
treat cluster.seeds (and the join ticket) as integrity-protected input: a
signed/Secret source in Kubernetes, not a mutable plain ConfigMap.
Revocation is durable and re-admit-proof (F6)
Removing a node writes a durable revocation tombstone keyed on its full mesh
public key. A fresh join token cannot silently re-admit a just-removed key — an
explicit un-revoke is required first. A remove racing an in-flight join always
resolves to removed.
See also
The security posture model
The security posture is boatramp’s answer to one question: who do you trust? A platform that serves one operator’s own sites on a private network can be loose in ways that a platform hosting untrusted tenants on the public internet must not. Rather than scatter that judgment across dozens of individual defaults, the posture makes it one explicit, inspectable decision.
Why it is operator-only
The hazards a posture governs — running a public bind without auth, upload and
component size caps, whether a site may reach private-network upstreams, whether
compute may share the host kernel — are exactly the ones a site must not be
able to relax. So the posture lives only in the operator’s boatramp.cfg and is
never part of site config. A principal with site-write can change routing,
handlers, and content, but cannot widen the trust boundary.
This is why some capabilities are refused by default even though the code supports them: a site cannot declare a private-IP gateway upstream, and shared-kernel compute is off, until the operator opts in. It is also why the default requires every database-opening handler to make an explicit in-site tenancy decision and forbids any handler from reaching across tenants in a shared database — a cross-tenant leak is exactly the hazard a site writer must not be able to introduce by omission. See Isolate tenants within a project.
Knobs are the truth; profiles are sugar
A posture resolves to a set of knobs — concrete booleans and byte caps like
allow_unauthenticated_public_bind, max_upload_bytes, and
allow_shared_kernel_compute. Those knobs are what the server actually enforces.
A profile is a named bundle of knob values, nothing more:
multi-tenant(the default) assumes untrusted site writers on an untrusted network and sets every knob to its strict value.single-tenantassumes one operator who owns every site and relaxes the knobs that only matter between mutually-distrusting tenants.devassumes local development and loosens loopback-only conveniences.
Overrides layer individual knobs on top of a profile, so you start from a
coherent baseline and adjust one thing without silently loosening others. Because
the knob is the unit of enforcement, boatramp security explain can always show
the resolved value and its source — profile or override.
The default is strict on purpose
The multi-tenant default fails closed: a non-loopback bind refuses to start
without auth, uploads and components are capped, private upstreams and
shared-kernel compute are denied. An operator who wants less must say so
explicitly. That ordering — safe by default, dangerous only on request — is the
whole point of having a posture rather than a pile of independent flags.
To set and inspect one, see Choose & inspect a security posture.
The configuration model
boatramp’s configuration lives in two tiers, and the split is intentional: it is drawn by what should be operator-changeable at runtime versus what is a trust anchor a runtime compromise must not be able to touch.
The two tiers
Static (boatramp.cfg). A per-node file, read once at serve startup. It
holds the trust anchors and listener shape: the auth root key / external signer,
the bootstrap secret, TLS, the bind address, the cluster identity, and the
[security] posture. Changing any of it needs editing the file and restarting
the process. The restart is a feature, not a limitation — a bad file fails fast at
boot, and, crucially, changing it requires host access, a stronger credential
than any API token.
Dynamic (the control plane). Operational knobs stored in the KV, changed
through the authenticated API with boatramp config.
A write converges fleet-wide without a restart — it replicates like any
control-plane object, and every node reloads on the change notification. This is
the tier for the settings an operator actually retunes: the default site, upload
caps, and the fleet default microVM kernel.
The server runs on effective = file baseline ⊕ dynamic overrides.
Why the anchors stay static
The static file’s security value is that mutating it needs host access, not an API token. If the trust anchors or the trust-relaxing posture knobs were API-writable, a single stolen admin token — or a compromised cluster leader — could re-root trust or disable a defense across the whole fleet. So those settings are deliberately not fields of the dynamic config: the burden of proof is on making a knob dynamic, not on keeping it static.
Two rules keep the dynamic tier safe even for the knobs that are exposed:
- Static ceilings. A dynamic numeric cap may only move within the posture’s bound — it can tighten, never exceed it.
- Tighten-only posture. A dynamic
posture.*override may only move a knob toward the safe value (harden a running fleet); loosening always requires the file + a restart.
So an operator gets no-restart, cluster-wide changes for the things they retune, without any trust boundary moving onto the network-reachable tier.
Change class
Every setting has a change class you can query:
| Class | Where | How to change |
|---|---|---|
dynamic | KV / control plane | boatramp config set … — fleet-wide, no restart |
restart | boatramp.cfg | edit the file on each node + restart |
boatramp config describe <key> reports a key’s class, and config set on a
restart-class key fails with a pointer to boatramp.cfg — so editing the file
and expecting a live reload can’t silently do nothing.
See the dynamic daemon config reference for the full key list, ceilings, and the ratchet.
Compute: functions and their runtimes
The unit of compute is a function — a portable WASI component. Its runtime is a separate choice: where that function’s code executes. The three runtimes differ in isolation, startup cost, and what code they can run; pick the lightest one that fits. This is one knob on the function, not three different kinds of compute.
The three runtimes
Wasm (the default) — the component runs in an in-process wasmtime sandbox with
capability-based host bindings (kv, sql, blobstore, messaging). Instantiation is
sub-millisecond, memory is small, and the sandbox is strong because the guest can
only touch what you grant. The constraint is the model: the code must compile to a
wasi:http component. This is the runtime a handler
(a route-triggered function) uses, and the one to reach for first.
Container — an OCI image run as a long-lived workload with a shared host kernel, isolated with a jailed worker, namespaces, cgroups, and a seccomp filter. It runs any Linux program, starts quickly, and is memory-efficient, but it shares the kernel — so it is appropriate for code you trust.
microVM — a rootfs image run inside a Firecracker-class virtual machine
with its own kernel (build one from an OCI image with compute build). It gives
hardware-level isolation for untrusted or tenant-supplied code, at the cost of a
heavier boot and a kernel per instance.
boatramp ships both an external-Firecracker backend and an embedded rust-vmm
backend; a microVM backend is available on Linux hosts with /dev/kvm.
The root filesystem is typed
A non-Wasm runtime boots from a root filesystem source, and the three
substrates accept three different artifact forms. Since 0.2.0 this is a typed
RootSource with one variant per form — not one overloaded string — so a mismatch
is a typed error at declare time rather than a silent runtime failure:
image— an OCI image reference the backend pulls (thedockerandcloudflaresubstrates).tar— a tar rootfs archive, unpacked for the nativecontainerruntime.rootfs— a rootfs filesystem image (a block device;ext4by default), which thefirecrackermicroVM mounts alongside its kernel.
boatramp compute set takes exactly one of --image / --tar / --rootfs,
matched to the target substrate; compute build produces a rootfs from an OCI
image. See Run a container or microVM.
Choosing
| Wasm | Container | microVM | |
|---|---|---|---|
| Isolation | in-process capability sandbox | shared kernel + namespaces | own kernel (hardware) |
| Startup | sub-millisecond | fast | boot (or restore) |
| Runs | wasi:http components | any Linux program | any Linux program |
| Trust | any | code you trust | untrusted / tenant code |
A function selects its runtime with a runtime knob (wasm by default); the
trigger, versioning, and addressing are the same whichever runtime executes it.
The isolation choice is also a posture decision. Under the strict multi-tenant
security posture, shared-kernel (container) compute is
disabled, so a workload marked --isolation untrusted — or any workload under
that posture — runs in a microVM. A single-tenant operator who owns every
image can allow containers for their lower overhead.
Scale to zero
A microVM workload can snapshot its running state and stop when idle, then restore on the next request, so an idle service costs nothing. A restore resumes the guest where it paused rather than booting it. See Scale compute to zero.
Where it runs
The control plane schedules workloads across nodes that advertise compute
capacity and reconciles the running replicas toward the desired count. The
backends are capability-detected per host (container where allowed, microVM where
/dev/kvm exists), so the same workload definition runs wherever it can. See the
architecture overview.
Deployment topologies & the one-UX seam
boatramp runs as a single node, a self-hosted Raft cluster, or on Cloudflare
Containers. The same binary, commands, and config work in all three. The
differences live behind trait seams — Storage, KvStore, Messaging — not in
the way you operate it. This page explains the topologies and the seam that keeps
them uniform.
The seam
boatramp keeps two kinds of state apart: blobs (file contents, streamed and
content-addressed) behind the Storage trait, and metadata (manifests, the
per-site current pointer, config, tokens, certs) behind the KvStore trait.
Swapping a backend is swapping a trait implementation, so the CLI, the routing,
and the config never change. That is why “the same commands run everywhere” is
true rather than a slogan — the environment-specific code is confined to the
backends, and everything above them is shared.
Single node
One process, local disk: FsStorage for blobs, embedded SlateDB for the KV. It
is a single writer and a single point of failure, which is the right trade for
most sites. SlateDB runs over any object store, so a single node can keep its KV
on S3 or R2 too. See Deploy a single node.
Shared-store frontends
Several stateless serving processes can share one KV over an object store, with a changelog keeping their in-memory caches coherent. This scales reads horizontally without Raft: the processes hold no authoritative state of their own, so you add and remove them freely. See Cache coherence.
Self-hosted cluster
A Raft cluster replicates the control plane. Writes commit to the leader’s replicated log; every node serves reads from its local applied state. Voters form the quorum in one region; learners in other regions serve local reads and forward writes, so a far-region node gives low-latency reads without a WAN round-trip on every request. The peer mesh runs over raw-public-key mutual TLS. See Deploy a self-hosted cluster.
Cloudflare Containers
The same binary runs in Cloudflare Containers as a single durable instance,
with a thin edge Worker in front. The Worker runs the pure boatramp_core::route
logic compiled to Wasm, so the edge routes exactly as the origin does — there is no
separate routing implementation to keep in sync, and no separate coordinator
service. Durability moves to R2 behind the same Storage / KvStore seams: blobs
over the S3 API and the control-plane metadata as a SlateDB store on the same
bucket (the handler sql binding uses D1/libsql). A multi-node Raft quorum isn’t
possible on the platform (CF Containers scale to zero and have no
container-to-container networking), so the durable single writer is the Cloudflare
topology. See Deploy on Cloudflare Containers.
Choosing
- One host, most sites → single node.
- Read scale without HA writes → shared-store frontends.
- Highly available control-plane writes, multi-region reads → cluster.
- Cloudflare’s edge and managed backends → Cloudflare Containers.
The choice is an operational one. Because it is a backend choice behind the seam, you can start on one node and move to a cluster later without rewriting anything.
Maturity, validation & support
boatramp is pre-1.0. The core is feature-complete and tested; some capabilities that depend on real cloud or multi-host environments are validated at the mechanism level and have a remaining live-operation seam. This page states, per capability, what “done” means so you can judge what to run in production.
What “validated” means here
Every capability has unit and integration tests that run in CI, plus native
validation of its mechanism. Some also have a live seam — an #[ignore]d test or
an operational path that needs a real cluster, cloud account, or KVM host to
exercise end to end. A live seam means the code is written and the mechanism is
proven; the remaining work is real-environment operation, not implementation.
Status by capability
| Capability | Status |
|---|---|
| Static hosting, atomic deploys & rollback | Stable. |
| Routing (redirects, rewrites, headers, SPA) | Stable. |
| Domains, TLS, ACME (HTTP-01 + DNS-01) | Stable. |
| Auto-DNS (10 managed providers) | Stable; each cloud provider’s live round-trip is a per-provider seam (Cloudflare validated against a real zone). |
| Authentication, RBAC, external signers | Stable; KMS/HSM/Vault backends have live seams for the specific service. |
| Wasm handlers + host bindings | Stable. |
| Caching, compression, observability | Stable. |
| Single-node deployment | Stable. |
| Clustering (Raft) | In-process complete; live multi-host operation is the remaining seam. |
| Compute — containers & microVMs | The backends and the embedded VMM boot and serve real images; scale-to-zero snapshot/restore is validated live. The automatic idle→snapshot reconcile and VMM persistent volumes are being finished. |
| Cloudflare Containers target | Native deploy over the CF REST API (no wrangler), validated live: /healthz + an authenticated control-plane round-trip through the edge → DO → container, with durable state in R2 (blobs + a SlateDB KV). A single durable instance — a multi-node Raft quorum isn’t possible on the platform. |
Support
There is no compatibility guarantee before 1.0: config formats, CLI flags, and the KV keyspace may change between releases. Pin a version, read the release notes before upgrading, and back up before you do (see Back up & restore).
For the up-to-date, code-level status of any specific area, the repository’s roadmap is authoritative — the tables above summarize it but the code and its tests are the source of truth.
CLI
boatramp is one binary: the server (serve) and every client command. This
page documents each command. Any command also prints its own flags with
boatramp <command> --help, and group commands list their sub-actions with
boatramp <command> help.
Precedence for any overridable value: flag / environment variable > config
file > built-in default. Project commands read project.cfg; serve reads
boatramp.cfg.
Global flags
| Flag | Description |
|---|---|
--config <path> | Config file (project.cfg for client commands, boatramp.cfg for serve). |
-h, --help | Print help for the binary or a subcommand. |
-V, --version | Print the version. |
Common client flags
Most client commands accept these, so the per-command tables below list only the flags unique to each command:
| Flag | Env | Description |
|---|---|---|
--server <url> | BOATRAMP_SERVER | Server base URL (overrides publish.server). |
--site <name> | BOATRAMP_SITE | Target site (overrides publish.site). |
--project <name> | BOATRAMP_PROJECT | Target project for site-scoped commands. Falls back to [publish].project → the reserved default project; omitting it is byte-identical to pre-0.2.0. |
| — | BOATRAMP_SERVER_PUBKEY | Pin the control plane to a --tls rpk server’s raw public key (the hex it prints at startup). See Reach the control plane on day zero. |
Commands
| Command | What it does |
|---|---|
serve | Run the HTTP server and publishing API. |
project | Manage projects — the Workspace that owns sites, functions, and compute. |
apply | Reconcile a whole project (sites + functions + compute) from a declarative apply.cfg manifest. |
migrate | Migrate a pre-0.2.0 control-plane store to the project-scoped layout. |
sync <dir> | Build (optional) and publish a folder as a new atomic deployment. |
build | Run the configured build command only. |
bundle | Bundle JS/TS + CSS in-process (bundler feature). |
compose | Fuse several Wasm components into one linked handler. |
validate | Parse and check a project.cfg (its routing section). |
deployments | List a site’s deployment history. |
rollback | Roll back to the previous (or a specific) deployment. |
status | Show a site’s current deployment. |
domain | Attach/detach hostnames to a site. |
alias | Manage named pointers to deployments. |
access | Configure visitor access control. |
handlers | Manage a site’s handler policy (enable/disable, import allowlist, caps, edge cache, cookie auth). |
function | Manage top-level functions (deploy, invoke, triggers, local dev). |
graphql | Manage a project’s GraphQL admin (persisted-op safelist + federation subgraphs). |
tenancy | Manage a project’s tenancy schema (the per-table tenant-key map). |
secrets | Manage a project’s internal sealed secret store. |
email | Manage a project’s SMTP delivery profiles (email feature). |
sql | Operator SQL to a managed database (apply a migration, run a query, probe reachability). |
token | Manage control-plane API tokens. |
cluster | Operate a cluster’s dynamic-join membership. |
operator | Run the in-binary Kubernetes operator / print its manifests. |
security | Inspect the operator security posture. |
auth | Generate/inspect the root key; edit the RBAC policy. |
gateway | Publish a private service through the reverse-proxy gateway. |
compute | Manage microVM compute workloads. |
db | Inspect the project’s declarative managed databases (read-only). |
blob | Upload artifacts, and migrate/drain/purge the node blob backend. |
config | Read/change the dynamic daemon config (no restart); migrate an apply manifest. |
mcp | Run the Model Context Protocol server (drive boatramp from an AI agent). |
dns | Configure DNS and issue wildcard preview certs (acme-dns feature). |
logs | Tail a site’s captured guest stdout/stderr. |
stats | Show handler stats, consumer lag, and dead letters. |
dlq | Purge or redrive a consumer topic’s dead-letter queue. |
prune | Delete orphan deployments and unreferenced blobs. |
scrub | Verify every stored blob still hashes to its key. |
cert-status | Show cluster-managed certificate status. |
completions <shell> | Print a shell-completion script. |
man | Render the man page to stdout. |
cloudflare | Deploy to Cloudflare Containers natively over the REST API (cluster feature). |
Exit status is 0 on success and non-zero on failure; see
Errors & exit codes.
boatramp serve
Run the server: selects backends, TLS, auth, and (with the cluster feature)
cluster mode. The cluster: and compute: sections are configured in
boatramp.cfg, not on the command line.
The config file (--config <path>, default boatramp.cfg) is RON or JSON —
auto-detected by extension; pass --format <ron|json> to override (e.g. a
Nickel-generated config on a non-.json path). See
Author configs in RON or JSON.
Address, storage, cache
| Flag | Env | Default | Description |
|---|---|---|---|
--addr <host:port> | BOATRAMP_ADDR | 127.0.0.1:8080 | Bind address. |
--data-dir <path> | BOATRAMP_DATA_DIR | ./data | Blob + KV root for the filesystem backends. |
--blobs <fs|s3|gcs|azure> | BOATRAMP_BLOBS | fs | Blob backend (s3/gcs/azure are in the default build). |
--kv <slatedb|memory|cloudflare> | BOATRAMP_KV | slatedb | KV backend (cloudflare is in the default build). |
--kv-s3 | BOATRAMP_KV_S3 | false | Run the SlateDB KV on the S3/R2 object store (reusing the --blobs s3 config) instead of local disk — durable metadata for a volumeless container. |
--kv-s3-prefix <prefix> | BOATRAMP_KV_S3_PREFIX | _kv | Key prefix for the --kv-s3 store within the bucket. |
--s3-bucket <name> | BOATRAMP_S3_BUCKET | — | S3/R2 bucket (--blobs s3 and/or --kv-s3). |
--s3-endpoint <url> | BOATRAMP_S3_ENDPOINT | — | S3 endpoint (MinIO / R2). |
--s3-region <region> | BOATRAMP_S3_REGION | — | S3 region (R2: auto). |
--s3-path-style | BOATRAMP_S3_PATH_STYLE | false | Use path-style S3 addressing (R2 accepts it). |
--gcs-bucket <name> | BOATRAMP_GCS_BUCKET | — | GCS bucket (--blobs gcs). Credentials via Application Default Credentials. |
--gcs-endpoint <url> | BOATRAMP_GCS_ENDPOINT | — | GCS endpoint (a fake-gcs-server emulator). |
--gcs-anonymous | BOATRAMP_GCS_ANONYMOUS | false | Skip GCS credential resolution (the emulator). |
--azure-account <name> | BOATRAMP_AZURE_ACCOUNT | — | Azure storage account (--blobs azure). |
--azure-container <name> | BOATRAMP_AZURE_CONTAINER | — | Azure container (--blobs azure). |
--azure-access-key <key> | BOATRAMP_AZURE_ACCESS_KEY | — | Azure shared-key auth (prefer the env var). |
--azure-emulator | BOATRAMP_AZURE_EMULATOR | false | Use the Azurite emulator (well-known dev credentials). |
--cache-entries <n> | — | 256 | Front metadata cache size. |
Authentication
| Flag | Env | Description |
|---|---|---|
--auth-root-private-key <alg:hex> | BOATRAMP_AUTH_ROOT_PRIVATE_KEY | Root key: verify and mint tokens. |
--auth-root-public-key <alg:hex> | BOATRAMP_AUTH_ROOT_PUBLIC_KEY | Root key: verify only. |
--bootstrap-secret <secret> | BOATRAMP_BOOTSTRAP_SECRET | Single-use secret enabling token bootstrap. |
--oidc-issuer <url> | BOATRAMP_OIDC_ISSUER | Enable OIDC → token exchange for this issuer. |
--oidc-audience <aud> | BOATRAMP_OIDC_AUDIENCE | Required audience claim. |
--oidc-scope-claim <name> | BOATRAMP_OIDC_SCOPE_CLAIM | Claim mapped to boatramp roles. |
Warning: with no root key, control-plane auth is disabled. Under the default
multi-tenantposture,serverefuses to start that way on a non-loopback--addr. Configure a key, bind127.0.0.1, or select a looser security posture.
TLS
| Flag | Default | Description |
|---|---|---|
--tls <off|custom|acme|acme-dns|rpk> | off | TLS mode (HTTPS needs the tls feature). rpk = a pinned raw-public-key control channel; see Reach the control plane on day zero. |
--tls-cert <path> / --tls-key <path> | — | Certificate + key for --tls custom. |
--acme-domain <domain> | — | Domain to issue for (repeatable). |
--acme-directory <url> | Let’s Encrypt production | ACME directory URL. |
--acme-contact <email> | — | ACME account contact. |
--acme-ca-cert <path> | — | Extra CA root (for a private ACME CA). |
--acme-cache <path> | ./data/acme | Certificate cache directory. |
--acme-dns-provider <name> | manual | DNS-01 provider (--tls acme-dns); see DNS providers. |
--acme-wildcard-preview | false | Also issue *.deploy.<domain> for by-id previews. |
--http-redirect-addr <host:port> | BOATRAMP_HTTP_REDIRECT_ADDR | Second listener that 308s plain HTTP to HTTPS. |
Uploads, serving, cluster
| Flag | Env | Default | Description |
|---|---|---|---|
--max-upload-bytes <n> | BOATRAMP_MAX_UPLOAD_BYTES | unlimited | Reject larger blob uploads. |
--upload-idle-timeout-secs <n> | BOATRAMP_UPLOAD_IDLE_TIMEOUT | — | Abort an upload idle this long. |
--max-concurrent-uploads <n> | BOATRAMP_MAX_CONCURRENT_UPLOADS | — | Cap simultaneous uploads. |
--default-site <name> | BOATRAMP_DEFAULT_SITE | — | Site served for an unmatched Host (see addressing). |
--pop-origin <url> | BOATRAMP_POP_ORIGIN | — | Canonical origin a per-request proof-of-possession must bind. Required for holder-bound (cnf/PoP) tokens. See PoP-bind a token. |
--protect-previews | BOATRAMP_PROTECT_PREVIEWS | false | Require a token to view /_deploy previews. |
--auto-migrate | — | false | Migrate a pre-0.2.0 store to the project-scoped layout at startup instead of refusing to serve. The migration is online, idempotent, and resumable; see migrate for the explicit operator step. |
--cluster-rate-limit | BOATRAMP_CLUSTER_RATE_LIMIT | false | Rate-limit cluster-wide via the KV, not per node. |
--shared-cache-coherence | BOATRAMP_SHARED_CACHE_COHERENCE | false | Keep the config cache coherent across processes sharing one KV. |
--cluster-init | BOATRAMP_CLUSTER_INIT | false | Found a new cluster from this node (explicit, one-time). See Deploy a cluster. |
--cluster-join <ticket> | BOATRAMP_CLUSTER_JOIN | — | Join an existing cluster with a one-paste ticket from cluster add. |
--cluster-advertise-addr <url> | BOATRAMP_CLUSTER_ADVERTISE_ADDR | https://<cluster.listen> | This node’s reachable mesh URL peers dial (set behind NAT / 0.0.0.0). |
boatramp serve --config boatramp.cfg \
--addr 0.0.0.0:8080 --tls acme --acme-domain pad.example.com
boatramp project
Manage projects — the Workspace that owns sites,
functions, and compute, and is the tenant boundary a handler’s row-level scope
resolves to. Takes the common --server flag.
| Sub-action | Description |
|---|---|
create <name> | Create a project. <name> is a slug (no /). Flags: --display <name>, --description <text>, --region <name> (default region for the project’s compute/replicas). |
ls | List all projects. |
show <name> | Print one project’s full record. |
rm <name> | Delete a project. Refused (with an enumeration of what remains) while it still owns resources, and always for the reserved default. --force cascades the teardown (deprovision managed DBs, remove compute workloads + volumes, delete functions + sites, clear secrets + GraphQL safelist, then remove the project); --dry-run prints what would be destroyed and changes nothing; --force prompts for a typed-name confirmation unless --yes/-y (required when stdin isn’t a terminal). |
boatramp apply
Reconcile a whole project — its member sites (each a content dir + optional
build + routing + config), top-level functions, and compute workloads — from one
declarative RON manifest, in a single pass. Sites reuse the content-addressed
sync flow (upload only the missing blobs, then activate); functions and compute
are create-or-replace. apply is pure upsert and never prunes, so declarative
and imperative (CLI/API) management coexist. See
Declare a project with apply.
| Flag | Default | Description |
|---|---|---|
-f, --file <path> | apply.cfg | The project manifest (RON or JSON — auto-detected by extension). |
--format <ron|json> | auto | Manifest format. Auto-detected from the extension (.json ⇒ JSON, else RON); set explicitly when piping Nickel/JSON to a non-.json path. See Author configs in RON or JSON. |
--server <url> | — | Server base URL (overrides [publish].server; env BOATRAMP_SERVER). |
--dry-run | — | Print the plan (what would be built/deployed/activated) and mutate nothing. |
--build | — | Run each site’s configured build command before publishing it. |
The target project is the manifest’s project: field, else the global
--project / default.
boatramp migrate
Migrate a pre-0.2.0 control-plane store to the project-scoped layout (mutable
per-name records re-key under project/<proj>/…; no content-addressed body moves).
The migration is online, idempotent, and resumable. serve refuses an unmigrated
store unless started with --auto-migrate. See
Upgrade a store to project scoping.
| Flag | Default | Description |
|---|---|---|
--data-dir <path> | BOATRAMP_DATA_DIR | Blob + KV root (the store to migrate). |
--kv <slatedb|memory|cloudflare> | slatedb | KV backend. |
--dry-run | — | Scan and print the rewrites; write nothing. |
--stage | — | Copy-only pass: write the new keys but leave the old ones for a soak/rollback window (the 2-dual state). |
--finalize | — | Delete the old-layout keys left by an earlier --stage, completing the migration. |
A plain boatramp migrate (no --stage) copies and finalizes in one shot.
Three unrelated
migrateverbs. Don’t confuse them:
boatramp migrate(this top-level command) — the one-time pre-0.2.0 control-plane store re-key to the project-scoped layout.boatramp blob migrate— an offline, node-local copy of the whole blob backend from one storage config to another (fs→cloud, region→region).boatramp config migrate— a client-side upgrade of anapplymanifest to the current schemaversion.They share only the word “migrate”; each help text cross-disambiguates.
boatramp sync
Build (optional) and publish a folder as a new atomic deployment. Argument:
[PATH] — the directory to publish (defaults to build.output, then .).
| Flag | Description |
|---|---|
--build / --no-build | Force or skip the configured build command. |
--no-activate | Upload the deployment but do not make it current. |
-m, --message <msg> | Deploy message recorded with the deployment. |
--source <rev> | Source revision (defaults to the current git commit SHA). |
--branch <branch> | Source branch (defaults to the current git branch). |
--author <author> | Deploy author. |
boatramp build
Run the configured build command only.
| Flag | Description |
|---|---|
--command <cmd> | Override the configured build command. |
boatramp bundle
Bundle JS/TS (Rolldown) + CSS (lightningcss) in-process. Needs the bundler
feature; configured by the bundle section of project.cfg.
boatramp compose
Fuse a root (“edge”) component with one or more plugin components into a single
linked component, in-process — no external toolchain, no network hop. The fused
component’s exports are unchanged (still e.g. wasi:http/incoming-handler); only
the imports a plugin satisfies are linked internally, while host imports
(wasi:http, sql, kv, …) stay imported for the runtime to supply. Deploy the
one fused .wasm through the normal content-addressed path. See
Compose components into one handler.
| Flag | Description |
|---|---|
--edge <COMPONENT> | The root component: exports the handler world, imports what the plugins provide. |
--plugin <COMPONENT> | A plugin whose exports satisfy one of the edge’s imports. Repeatable. |
-o, --output <PATH> | Where to write the fused component. |
boatramp validate
Parse and check a project.cfg (its routing section). Argument: [PATH] —
the config to validate (default project.cfg). See the
routing schema.
boatramp deployments
List a site’s deployment history.
| Flag | Default | Description |
|---|---|---|
--limit <n> | 20 | Maximum number of deployments to show. |
boatramp rollback
Roll back to the previous (or a specific) deployment.
| Flag | Description |
|---|---|
--to <id> | Deployment id (or unique prefix) to activate. Defaults to the previous one. |
boatramp status
Show a site’s current deployment (id, age, size). No command-specific flags.
boatramp domain
Attach/detach hostnames to a site (virtualhost routing). See Attach a custom domain.
| Sub-action | Description |
|---|---|
add <host> | Verify ownership and attach (use *.example.com for a wildcard). Verifies + attaches in one step when the host already resolves here; otherwise prints the challenge to finish with verify. |
verify <host> | Check the challenge; on success the host is attached. |
rm <host> | Detach a hostname and drop its verification. |
ls | List the site’s hostnames and pending verifications. |
domain add flags:
| Flag | Default | Description |
|---|---|---|
--method <http|dns> | http | Serve a token file (http) or publish a TXT record (dns, needs domain-verify-dns). |
--provider <name> | — | Managed-DNS provider (e.g. cloudflare, route53): publish the _boatramp-verify TXT, poll, and attach — no manual DNS edit. Implies --method dns; needs acme-dns. |
--no-wait | — | Only start the challenge and print instructions; skip the immediate verify+attach self-check. |
boatramp alias
Manage named pointers (staging, previews) to deployments. See Publish, roll back & alias.
| Sub-action | Description |
|---|---|
set <name> <deployment> | Point an alias at a deployment id (or unique history prefix). |
rm <name> | Remove a named alias. |
ls | List the site’s aliases. |
boatramp access
Configure visitor access control. See Restrict visitor access.
| Sub-action | Description |
|---|---|
show | Show the site’s current access-control policy. |
basic-auth add|rm|clear | Manage HTTP Basic auth credentials. add reads the password from --password or stdin. |
ip allow|deny|clear | Manage IP allow/deny rules (CIDR or bare address); deny wins over allow. |
rate-limit set|off | Set the per-client requests/second (+ optional burst) or disable it. |
trusted-proxy add|clear | Trust a reverse proxy by CIDR so its X-Forwarded-For is believed. |
boatramp handlers
Manage a site’s handler policy — enable/disable, the import allowlist, resource caps, the edge response cache, and browser cookie auth. See Handler host bindings.
| Sub-action | Description |
|---|---|
show | Show the site’s current handler policy. |
enable [--allow <import>…] [--max-memory-mb <n>] [--max-timeout-ms <n>] [--max-concurrency <n>] [--max-fuel <n>] | Enable handlers on the site. --allow (repeatable) replaces the whole import allowlist when given; the cap flags are optional. |
disable | Disable handlers on the site (keeps the allowlist + caps for a later re-enable). |
allow <import>… | Add interface(s) to the site’s import allowlist. |
deny <import>… | Remove interface(s) from the site’s import allowlist. |
cache enable [--max-entry-bytes <n>] [--max-ttl-secs <n>] / cache disable | Configure the edge response cache for handler routes. |
cookie-auth set --cookie-name <c> [--allowed-origin <o>…] / cookie-auth clear | Treat the named cookie as the application bearer (with an optional cross-origin CSRF allowlist), or turn cookie session auth off. |
boatramp function
Manage top-level functions — deploy, roll back/alias,
invoke, triggers, and the local dev harness. Takes a --server flag; site-scoped
sub-actions also read --site.
| Sub-action | Description |
|---|---|
ls [--site <s>] | List functions (optionally for one site). |
get <site>/<name> | Show one function by its <site>/<name>. |
deploy <name> <wasm> [--substrate wasm|microvm|container] [--webhook-secret-env <var>] [--webhook-ingress-topic <t>] | Deploy a version of a function from a component .wasm (uploaded as a blob). |
rollback <name> --to <version> | Roll a function’s active version back to a specific version. |
alias <name> <label> <version> | Point an alias label (e.g. prod, staging) at a version. |
rm <name> | Remove a top-level function. |
invoke <name> [--data <body>|--data-file <path>] [--content-type <ct>] [--async] [--idempotency-key <k>] [--version <v>] | Invoke a function; reads the body from --data/--data-file/stdin and prints the response. --async enqueues and prints an invocation id. |
invocation <name> <id> | Show a durable (async) invocation’s status/result by id. |
usage <name> | Show a function’s usage aggregate (invocations, duration, bytes). |
trigger add|ls|rm | Manage a function’s scheduled/event triggers: add takes exactly one of --cron / --queue / --blob. |
init <name> [--lang rust|js|python] [--dir <path>] | Scaffold a new function project from a language template. |
build [<dir>] | Build a function project to a wasi:http component; prints the produced .wasm path. |
test <wasm> [--path <p>] [--method <m>] [--body <b>] [--content-type <ct>] [--expect-status <n>] [--expect-body <substr>] | Run a component locally against one request and assert on the response. |
dev <wasm> [--port <n>] | Serve a component locally on an HTTP port (the local dev harness). |
boatramp graphql
Manage a project’s GraphQL admin — the persisted-operation safelist and the federation subgraph registry. See Serve a GraphQL API.
| Sub-action | Description |
|---|---|
safelist add [<op>|--file <path>] | Register a trusted operation (query text inline or from a file). |
safelist ls | List the registered trusted operations. |
safelist rm <hash> | Remove a trusted operation by its hash. |
subgraph put <name> <sdl-file> | Publish (or replace) a Wasm subgraph from its SDL file. |
subgraph sql <name> <json-file> | Publish (or replace) a SQL subgraph from a request JSON file ({site, config}). |
subgraph function <name> | Register (or refresh) a function subgraph by introspecting the deployed function of the same name. |
subgraph rm <name> | Remove a subgraph. |
supergraph | Print the composed supergraph SDL. |
boatramp tenancy
Manage a project’s tenancy schema — the per-table
tenant-key map that scopes guest sql/orm queries. Project·Admin. See
Isolate tenants in one database.
| Sub-action | Description |
|---|---|
show | Print the project’s current tenancy schema as JSON (a project with none prints the default tenant_id/no-tables schema). |
apply <file> | Replace the project’s tenancy schema from a JSON file (as produced by tenancy show). |
clear | Clear the schema, reverting to legacy single-column scoping. Idempotent. |
boatramp secrets
Manage a project’s internal, sealed secret store
(a guest names a secret as boatramp:<name> in its secrets map). The store
holds only sealed bytes — the API never returns a value.
| Sub-action | Description |
|---|---|
set <name> [--stdin|--file <path>|--value <v>] | Seal value under name (setting an existing name rotates it). Prefer --stdin/--file so the plaintext doesn’t hit argv/history. |
rotate <name> … | Alias for set (overwrite in place), for intent-clarity. |
ls | List the project’s secrets: name / revision / last-updated (never a value). |
rm <name> | Remove a secret by name. |
boatramp email
Manage a project’s SMTP delivery profiles (a guest’s email capability selects
one by name). Needs the email feature. Passwords stay sealed — never returned.
See Send email from a function.
| Sub-action | Description |
|---|---|
set <name> [--host <h>] [--port <n>] [--security starttls|tls|plaintext] [--username <u>] [--password/--password-stdin] [--no-auth] [--from <addr>] [--durable <bool>] | Create or update an SMTP profile — fields you pass overwrite, fields you omit keep their stored value (the sealed password is preserved unless you pass a new one). --host + --from are required on create. |
ls | List the project’s SMTP profiles (redacted). |
show <name> | Show one profile’s redacted config. |
rm <name> | Remove a profile by name. |
boatramp sql
Operator SQL to a managed database. See Handler bindings.
| Sub-action | Description |
|---|---|
exec [--db <name>] [--file <path>] | Apply a migration script (multiple statements — CREATE EXTENSION, tables, RLS, chained DDL/DML) to a managed database, from --file or stdin. --db is the binding name (empty = the site’s default database). |
query <sql> [--db <name>] [--format table|json] | Run one row-returning query and print the result. |
ping [--db <name>] | Actively TCP-probe each replica of a managed database — a reachability check that bypasses the stored-health gate query trips on (distinguishes “the DB is down” from “up but the resolver won’t serve it”). Admin-scoped. |
boatramp token
Manage control-plane API tokens. See Bootstrap authentication and the RBAC reference.
| Sub-action | Description |
|---|---|
create <label> | Mint a token (printed once). |
bootstrap | Mint the first token with the single-use BOATRAMP_BOOTSTRAP_SECRET — no admin token needed. |
mint | Mint a token offline via the configured signer (local key or KMS/HSM), no server. |
attenuate <credential> | Narrow a delegatable token offline by signing a restrict-only block. |
ls | List issued tokens (short id, label, roles, expiry). |
rm <id> | Revoke a token by its id or a unique prefix. |
create / mint flags:
| Flag | Description |
|---|---|
--role <role> | Role, repeatable: <role> (global), <role>:<project>/<site> (site-scoped), or <role>:<project> (project-scoped). A legacy <role>:<site> is read as default/<site>. Required. See the RBAC reference. |
--ttl-secs <n> | Time-to-live in seconds (omit for no expiry). |
--holder-pub <alg:hex> | Make the token delegatable: embed this holder public key as the cnf. |
--pop | Make the token PoP-bound: generate a holder keypair, mint against its public half, and print BOATRAMP_TOKEN + BOATRAMP_TOKEN_HOLDER_KEY exports. Conflicts with --holder-pub. See PoP-bind a token. |
attenuate flags:
| Flag | Env | Description |
|---|---|---|
--holder-key <alg:hex> | BOATRAMP_HOLDER_KEY | Holder private key the parent block’s cnf authorized. Required. |
--only-site <site> | — | Restrict to a single site. |
--read-only | — | Restrict to read-only operations. |
--not-after <unix-secs> | — | Shorten the lifetime. |
--next-holder-pub <alg:hex> | — | Permit one further attenuation by this key; omit to make this the last block. |
boatramp cluster
Operate a self-hosted cluster’s dynamic-join membership. See Deploy a self-hosted cluster.
| Sub-action | Description |
|---|---|
add --root-pubkey <k> [--seed <addr>] [--ttl-secs <n>] [--print-token-only] | Print a one-paste join ticket (single-use token + seed + root anchor) for a new node. |
status [--full] | Show membership address-primary (ADDRESS/ROLE/NODE/STATE); --full shows whole node ids. |
promote <address|node> | Promote a caught-up learner to a voter (build a quorum on bare metal). Target the leader. |
remove <address|node> | Remove a node (subsumes revoke): revoke trust cluster-wide + drop from the quorum. Target the leader. |
join-token [--ttl-secs <n>] | Mint a raw single-use bearer join token (low-level; prefer add). |
rotate-key | Rotate the --server node’s own mesh key, make-before-break (node-local). |
revoke <node> | Revoke a node by raw node id (low-level; prefer remove). |
boatramp operator
Run the in-binary Kubernetes operator, or print its install manifests. See
Run on Kubernetes. The operator feature is in the
default (batteries-included) build; a minimal build re-adds it with --features operator.
| Sub-action | Description |
|---|---|
run [--namespace <ns>] | Run the controller: watch the boatramp CRDs and reconcile them. |
crds | Print the CRD YAML (BoatRampCluster / Site / Function). |
manifests | Print the full install bundle: CRDs + least-privilege RBAC + the operator Deployment. |
boatramp security
Inspect the operator security posture. See Security posture.
| Sub-action | Description |
|---|---|
explain | Print the resolved posture from boatramp.cfg (profile + every knob’s value and source). |
boatramp auth
Generate/inspect the control-plane root key and edit the RBAC policy. See Authentication & authorization.
| Sub-action | Description |
|---|---|
init | Generate a fresh ES256 root keypair. |
pubkey --private-key <alg:hex> | Derive the public key from a root private key. |
pin --root-pubkey <k> | Resolve a --tls rpk server’s TLS pin from the root anchor (prints BOATRAMP_SERVER_PUBKEY). |
rotate-root [--add <pubkey>] [--retire <pubkey>] | Make-before-break root rotation: trust a new anchor, or retire an old one; no flag lists the extra anchors. See Migrate the root key. |
policy get | Print the active RBAC policy as JSON (the built-in default if none is stored). |
policy set <file.json> | Replace the policy from a JSON file (validated server-side). |
boatramp gateway
Publish a private service through the reverse-proxy gateway. See Expose a private service.
| Sub-action | Description |
|---|---|
ls | List declared upstreams and routes. |
upstream add <name> … | Declare/replace an upstream: a single target, a pool of --backend URLs, or --discover-host/--discover-port for a DNS-discovered pool. |
upstream rm <name> | Remove an upstream and any routes that reference it. |
route add <match> <upstream> | Forward a path match to an upstream (appended to the end). |
route rm <match> | Remove the route with this match. |
boatramp compute
Manage Firecracker microVM compute workloads. See Run a container or microVM.
| Sub-action | Description |
|---|---|
ls | List workloads and their reconcile state. |
get <name> | Print one workload’s desired state as JSON. |
set <name> … | Create/update a workload from already-pushed rootfs/kernel blobs. |
build <name> … | Build an ext4 rootfs from an OCI image, upload it, and set the workload (needs mke2fs). |
rm <name> | Remove a workload (its replicas are stopped). Its persistent volume is left on disk — reclaim it with compute volume rm. |
exec <name> -- <cmd…> | Run a command inside a running workload replica (docker-exec style) — e.g. pipe a SQL file into psql, or run pg_dump. --stdin feeds this process’s stdin to the command. Requires the allow_compute_exec posture; container + docker backends only. |
volume ls | List persistent volumes on the node (NAME, SIZE, and whether a registered workload still references it). |
volume rm <name> | Remove a persistent volume. Refused (409) while a registered workload’s active spec still mounts it, unless --force. |
status [<workload>] [--format table|json] | Show observed per-replica runtime state — stored health, lifecycle phase, assigned IP:port, age vs startup grace, backend (the record the endpoint resolver reads). Node-global; admin-scoped. |
set-health <workload> <replica> --healthy <true|false> | Force one replica’s stored health flag — the escape hatch when a recovered replica is stuck healthy=false and the resolver won’t serve it. Node-global; admin-scoped. |
reconcile | Force the reconcile loop to run a convergence pass now (the “kick it” lever for a workload stuck mid-reconcile). Fire-and-forget; follow with compute status. Node-global; admin-scoped. |
restart <workload> <replica> | Stop one replica and let the reconcile loop relaunch a fresh one (re-running IP allocation) — the live workaround for a wedged replica or a stale IP. Node-global; admin-scoped. |
ip ls | List every replica’s assigned IP (IP/OWNER/HEALTHY), flagging duplicate-IP collisions. Node-global; admin-scoped. |
dns ls | List internal service-discovery names and the healthy replica IPs each resolves to. Node-global; admin-scoped. |
dns resolve <workload> | Resolve one workload’s internal name (in the --project tenant) to its healthy replica IPs — exactly as a same-project peer’s lookup would. Node-global; admin-scoped. |
netdiag <workload> | Actively TCP-probe a workload’s replicas from the node, alongside their stored state — REACHABLE=yes + HEALTHY=no is the reachable-but-not-served signature. Node-global; admin-scoped. |
set takes exactly one root-filesystem source (matched to the substrate);
build instead takes --image + --size-mib and produces a --rootfs source:
| Flag | Default | Description |
|---|---|---|
--image <ref> | — | An OCI image reference the runtime pulls (set: docker/cloudflare). On build, the OCI image to build an ext4 rootfs from. |
--tar <hash|file|url> | — | A tar rootfs archive for the native container substrate (set only). A blob hash, a local file, or a URL (file/URL is uploaded). |
--rootfs <hash|file|url> | — | A rootfs filesystem image (a block device — ext4 by default, or any filesystem the guest kernel mounts) for the firecracker micro-VM (set only). A blob hash, a local file, or a URL (file/URL is uploaded). |
--kernel <hash|file|url> | — | The vmlinux kernel the micro-VM boots (a --rootfs / build workload) — a blob hash, a local file, or a URL. See the kernel note. |
--size-mib <n> | 1024 | ext4 rootfs image size (build only). |
--port <n> | — | In-guest TCP port the app listens on. Required. |
--vcpus <n> | 1 | Virtual CPUs. |
--mem-mib <n> | 256 | Guest memory (MiB). |
--replicas <n> | 1 | Desired replica count. |
--entrypoint <arg> | — | In-guest entrypoint argv (repeatable). |
--env <K=V> | — | Environment variable (repeatable). |
--restart <always|…> | always | Restart policy. |
--scale-to-zero | false | Snapshot + stop when idle; restore on the next request. |
--startup-grace-secs <n> | 30 | Seconds a freshly launched replica has to become healthy before a still-unhealthy one is treated as a broken launch (stop + relaunch). Raise it for a slow-initializing image (a stock database’s first initdb). Managed databases use a larger per-engine default (Postgres 60, MySQL 120). |
--isolation <trusted|untrusted> | trusted | untrusted forces a microVM (never a shared kernel). |
--region <name> | — | Allowed placement region (repeatable; empty = any). |
The kernel blob
A microVM boots an uncompressed Linux kernel (vmlinux) plus an ext4 rootfs.
--kernel accepts a local file, a URL, or the content-addressed blob hash of
a kernel already uploaded; a file or URL is uploaded for you, and the server
fetches the blob and boots it. Supply a Firecracker-compatible vmlinux (build
one, or use a released microVM kernel) and provision it once, shared across
workloads. See Run a container or microVM.
boatramp db
Read-only inspection of a project’s managed databases. A managed database is
declared in the databases: block of an apply manifest — boatramp
then provisions it, mints and seals its credential, and follows the workload across
restarts. There is deliberately no db create: the manifest is the sole authoring
surface (a create verb would compete as a second source of truth), so this group only
reads what apply declared. The declaration and provisioning gate at Project·Admin;
these read verbs are Project·Read. Takes the common --server flag.
| Sub-action | Description |
|---|---|
ls | List the project’s declared managed databases (NAME, KIND, TENANT, SCOPE, SIZE). |
get <name> | Show one declared database’s declaration. |
status <name> | Show one declared database’s provisioning/health status (declaration + the derived server-workload handle). |
--json (a global flag on the group) emits the raw record instead of the human table.
To declare or change a database, edit the manifest’s databases: block — see
Declare a managed database,
Declare a project with apply, and the
project.cfg schema.
boatramp blob
Upload artifacts and manage the node’s blob storage backend. blob put is the
day-to-day artifact uploader; the migrate / drain / purge / status verbs move a
node from one blob backend to another (fs→cloud, provider→provider, region→region)
without a re-apply, then reclaim the old store.
| Sub-action | Description |
|---|---|
put <file> | Upload a file as a content-addressed blob; prints its hash (the key to pass to compute set --kernel/--rootfs). |
migrate | Offline, node-local copy of every object from one blob backend to another. |
drain | Daemon-mediated drain of a managed node’s configured read-fallback secondary → primary. |
purge | Reclaim unreferenced blobs, or a drained secondary’s objects (dry-run by default). |
status | Print the node’s blob migration posture ({ blob_fallback_active }). |
The three blob-storage
migrate/drainverbs.blob migrateis offline — it builds both backends in the CLI process from two node config files and never contacts a server, so it needs local access to the backends’ credentials (for a pre-boot copy, or a node you can shell into).blob drainis its daemon-mediated counterpart for a managed node reachable only over the control plane: the running daemon (which already holds both backends open) drains its own configured secondary.blob purge --drained-sourcethen removes the decommissioned old store. See Switch the blob backend with zero downtime. (boatramp blob migrateis unrelated to the top-levelboatramp migratestore re-key.)
blob migrate
Copy every object from a source blob backend to a destination backend, offline. It
is a node-local operation: it builds both backends in-process from node config files
(boatramp.cfg shape — each side’s [serve] blob block + optional [secrets] for a
sealed S3 credential), so it takes no --server and needs local credential access, not a
control-plane token. The copy get→puts each object, skipping any key already
head-present in the destination at a matching size — so it is idempotent and
resumable (a re-run after an interruption is a near-no-op) and read-only on the source
(it never deletes, and the source stays authoritative until you flip [serve].blobs). The
resolved source/dest identities are echoed and an equal source==dest is refused.
| Flag | Default | Description |
|---|---|---|
--from <config> | fallback secondary | Node config file whose [serve] blob block defines the source. Omitted ⇒ the --node-config’s [serve.blob_fallback] secondary (the drain one-liner); error if none. |
--to <config> | --node-config’s primary | Node config file whose [serve] blob block defines the destination. Omitted ⇒ the node config’s own primary backend. |
--node-config <path> | boatramp.cfg | Running node’s config — the source of the --from/--to defaults. Read only when --from or --to is omitted. |
--concurrency <n> | 8 | Bounded number of objects copied in flight at once. |
--no-verify | verify on | Skip the post-copy verify pass. By default, every source object is head-confirmed present in the destination; any miss is a non-zero exit. |
--dry-run | — | Enumerate + classify (would-copy / would-skip) and report; copy nothing. |
--prefix <p> | all | Restrict the copy to source keys under this prefix. |
--json | — | Emit the run summary as JSON instead of a human line. |
A verified fallback secondary → primary drain additionally prints
SECONDARY FULLY DRAINED — safe to remove [serve].blob_fallback and restart the node.
blob drain
Drain the running daemon’s configured [serve.blob_fallback] secondary → primary over
the control plane — for a managed node reachable only via --server (no local disk / no
fly ssh), where the offline blob migrate cannot run. The client names
no source or destination: the daemon (which already holds both backends of its fallback
composite open) drains only its own configured pair, a tighter authorization surface than
the offline CLI’s arbitrary --from/--to. Requires --server; gated at System·Admin
(a project admin / publisher / deployer cannot reach it). No fallback configured ⇒ 422.
Progress streams as it arrives; on a verified drain it prints
SECONDARY FULLY DRAINED — safe to remove [serve].blob_fallback. The copy is
resumable, so a dropped connection (e.g. an edge idle-timeout on a long drain) is safe to
re-run.
| Flag | Description |
|---|---|
--dry-run | Enumerate + classify on the daemon and report; copy nothing. |
--concurrency <n> | Bounded copy concurrency on the daemon. |
--prefix <p> | Restrict the drain to secondary keys under this prefix. |
--json | Emit the final report as JSON (progress lines stay on stderr). |
blob purge
Reclaim a provably-safe blob set over the control plane. Dry-run by default (reports
what would be reclaimed and deletes nothing) — pass --apply to execute. Gated at
System·Admin. Exactly one mode is required:
| Flag | Description |
|---|---|
--unreferenced | On-demand garbage collection: prune content-addressed blobs that no live deploy manifest references (the everyday storage reclaim). Refused (409) while a read-fallback secondary is attached ([serve.blob_fallback]) — drain and drop the fallback first, else GC could phantom-reclaim a secondary-only orphan. |
--drained-source | The migration decommission: after a drain/migrate, delete each old-secondary object only once it is byte-confirmed (present at a matching size) in the primary. Fail-closed — an unconfirmed object survives, never deleted. Requires a configured [serve.blob_fallback] (else 422). |
--apply | Actually delete (default: dry-run — report only). |
--prefix <p> | Restrict a --drained-source purge to source keys under this prefix (ignored by --unreferenced). |
--json | Emit the final report as JSON (progress lines stay on stderr). |
blob status
Print the node’s structured blob migration posture — whether a read-fallback secondary
is currently attached (the node is mid-migration) — as { "blob_fallback_active": <bool> }.
The direct answer to “is this node still in transition mode?”, instead of grepping the
startup log warning. Read-only (System·Read).
| Flag | Description |
|---|---|
--json | Emit the posture as JSON instead of a human line. |
boatramp config
Read and change the dynamic daemon config — operational knobs that converge fleet-wide without a restart. See the dynamic daemon config reference and the configuration model.
| Sub-action | Description |
|---|---|
get [key] | Print the active config + its generation, or one key’s value. |
set <key> <value> | Set one dynamic key (null/unset clears it); converges fleet-wide, validated server-side. |
rollback | Revert to the previous generation. |
apply -f <file> | Replace the whole dynamic config from a JSON file. |
list | List the dynamic (runtime-settable) keys. |
describe <key> | A key’s change class (dynamic vs restart). |
migrate <file> [--write] | Upgrade an apply manifest to the current schema. Client-side only — no server is contacted. |
config set on a restart-class key (a trust anchor, posture, or listener
setting) fails with a pointer to boatramp.cfg rather than silently doing
nothing.
config migrate
config migrate <file> upgrades an apply manifest to the current
schema and prints the result (or, with --write, rewrites the file in place). It is
purely client-side — it reads and rewrites the file locally and never contacts a server.
The version: semantics drive it:
- Absent
version:— the manifest is parsed strictly against the current schema. An old-shaped manifest that omitsversion:fails with an error pointing here. version: Nolder than current — the manifest opts into the loose-parse → migrate → typed pipeline: it is migrated forward through the registered chain (vN → … → current).- The migrated output omits
version:(current = absent). Declareversion: <what you wrote against>only to keep migration support for a future schema change.
# Upgrade a pre-v0.6.0 manifest in place (add `version: 1` at its top first).
boatramp config migrate apply.cfg --write
See Declare a project with apply for the manifest schema and the
version-migration workflow.
boatramp mcp
Run the Model Context Protocol server so an AI
agent (Claude, Codex, …) can drive one or more instances. Bare boatramp mcp
serves over stdio (what a desktop agent spawns); the server can also be reached
over HTTP at /mcp on any boatramp serve (on by default). See
Drive boatramp from an AI agent.
| Sub-action | Description |
|---|---|
(none) / serve | Serve the MCP protocol over stdio until the client disconnects. |
setup add <name> --server <url> [flags] | Register an instance in ~/.config/boatramp/mcp.toml. |
setup list | List the registered instances. |
setup remove <name> | Remove a registered instance. |
setup add flags: --token <spec> (an env:VAR / path:/file / literal token),
--holder-key <spec> (a cnf holder key for DPoP), --server-pubkey <hex> (pin
the server’s raw public key), --insecure (skip TLS verification). Secrets are
stored as specs, never resolved into the file.
boatramp dns
Configure DNS and issue wildcard preview certificates. Needs the acme-dns
feature. Every sub-action takes --provider <name>; each provider reads its
credentials from the environment (see DNS providers).
| Sub-action | Description |
|---|---|
setup --provider <p> --host <h> --target <t> | Create the *.deploy.<host> record so by-id preview subdomains resolve here. |
configure-domain <host> --provider <p> --target <t> | Point a verified custom domain at this server (upsert A/AAAA/CNAME). --proxied for Cloudflare orange-cloud. |
cert --provider <p> --host <h> | Issue/renew the *.deploy.<host> wildcard cert via ACME DNS-01. |
boatramp logs
Tail a site’s — or a standalone function’s — captured guest stdout/stderr. See Observe a running server.
| Flag | Default | Description |
|---|---|---|
--site <name> | BOATRAMP_SITE | Site whose guest logs to tail. Mutually exclusive with --function. |
--function <name> | — | Tail a standalone function’s captured stdout/stderr instead of a site (a GraphQL subgraph, auth function, or worker invoked via invoke, whose println!/eprintln! is otherwise unreadable). Scoped to --project; takes precedence over --site. |
--stream <stdout|stderr> | both | Only show one stream. |
--limit <n> | 200 | Number of recent lines to show. |
-f, --follow | — | Keep polling for new lines (like tail -f). |
boatramp stats
Show a site’s handler invocation stats, consumer lag, and dead letters. No command-specific flags.
boatramp dlq
Purge or redrive a consumer topic’s dead-letter queue. See Run background work.
| Sub-action | Description |
|---|---|
purge <topic> | Drop a topic’s dead-lettered messages (records + payloads). |
redrive <topic> | Requeue a topic’s dead-lettered messages with a fresh attempt count. |
boatramp prune
Delete orphan deployments and unreferenced blobs. See Prune & scrub.
| Flag | Default | Description |
|---|---|---|
--dry-run | — | Only report what would be removed. |
-y, --yes | — | Delete without confirmation. |
--keep-last <n> | — | Keep at most this many recent deployments per site. |
--keep-age <secs> | — | Also keep any deployment activated within this many seconds. |
--grace <secs> | 3600 | Never collect a deployment first seen this recently (races an in-flight deploy). |
boatramp scrub
Verify every stored blob still hashes to its key (integrity scrub). No command-specific flags.
boatramp cert-status
Show cluster-managed certificate status (domain + expiry). No command-specific flags.
boatramp completions / man
| Command | Description |
|---|---|
completions <shell> | Print a shell-completion script (bash, zsh, fish, …). |
man | Render the man page to stdout (boatramp man > boatramp.1). |
boatramp cloudflare
Deploy boatramp to Cloudflare Containers natively over the CF REST API (no
wrangler) — behind an edge Worker, as a single durable instance with all
state in R2. Needs the cluster feature and CLOUDFLARE_ACCOUNT_ID +
CLOUDFLARE_API_TOKEN (Workers Scripts, Containers, R2, D1 scopes). A multi-node
Raft quorum isn’t possible on the platform, so only --quorum 1 deploys. See
Deploy on Cloudflare Containers.
| Flag | Default | Description |
|---|---|---|
--region <code> | — | CF region to run in (repeatable; on CF only one deploys). |
--primary <code> | — | The primary region (must be one of --region). |
--quorum <n> | 3 | Voting nodes — must be 1 on Cloudflare (single durable instance). |
--image <ref> | boatramp:latest | Container image (pushed to a registry CF can pull). |
--domain <host> | — | Public domain the edge Worker serves (repeatable). |
--r2-bucket <name> | boatramp-blobs | R2 bucket for durable blobs + the SlateDB KV. |
--d1 <name> | boatramp-sql | D1 database for the handler sql binding. |
--auth-root-private-key <alg:hex> | env BOATRAMP_AUTH_ROOT_PRIVATE_KEY | Control-plane root key; generated + printed once if unset. |
--container-env <KEY=VALUE> | — | Extra env for the container (repeatable) — e.g. a handler’s webhook secret. |
--dry-run | false | Print the plan; mutate nothing. |
--emit-artifacts <dir> | — | Write reference artifacts (Dockerfile, edge Worker, node configs) instead of deploying. |
project.cfg schema
project.cfg is the per-project config, read by the client commands (sync,
build, bundle, validate). It is RON, lives
in the project folder, and is optional — a missing file means all defaults.
(
publish: ( server: "https://pad.example.com", site: "my-site" ),
build: ( command: "npm run build", output: "dist" ),
routing: (
clean_urls: true,
redirects: [ (from: "/old/:slug", to: "/new/:slug", status: 301) ],
),
)
Sections:
| Section | Purpose |
|---|---|
publish | Where and what to publish (sync). |
build | An optional build command run before sync. |
bundle | The in-process JS/CSS bundler (bundler feature). |
routing | Redirects, rewrites, headers, handlers — folded into the deployment. |
publish
| Field | Type | Description |
|---|---|---|
server | url | Server base URL. Flag --server, env BOATRAMP_SERVER. |
site | string | Site to publish to. Flag --site, env BOATRAMP_SITE. |
token | string | Control-plane token. Prefer BOATRAMP_TOKEN so it is not on disk. |
project | string | The project this config’s site belongs to; overridden by --project / BOATRAMP_PROJECT, defaults to default. |
See also the separate apply.cfg project manifest, which
declares a whole project — its member sites, top-level functions, compute
workloads, managed databases, and tenancy schema — as one applied unit. Its
field-by-field schema is below.
build
Run before sync; its output directory is what gets published.
| Field | Type | Description |
|---|---|---|
command | string | Shell command to run (e.g. npm run build). |
output | string | Directory the build emits and sync publishes (e.g. dist). |
bundle
The in-process bundler (Rolldown for JS/TS, lightningcss for CSS). Needs the
bundler feature.
| Field | Type | Default | Description |
|---|---|---|---|
outdir | string | dist | Output directory for bundled assets. |
js | list | — | JS/TS entry points (tree-shaken, code-split). |
css | list | — | CSS entry points (@import inlined). |
minify | bool | true | Minify the output. |
routing
The bulk of a project’s config: redirects, rewrites, headers, SPA fallback,
clean URLs, error documents, and the handler/consumer/cron/stream declarations.
It is compiled and checked at sync (and by boatramp validate), then folded
into the immutable deployment manifest — so it is atomic with the content and
rolls back with it.
The full field-by-field schema is on its own page: Routing config schema.
Validate a project.cfg (including routing) without publishing:
boatramp validate
project.cfg: routing OK (2 redirects, 1 handler)
apply.cfg manifest schema
apply.cfg is a separate, project-level RON
manifest read by boatramp apply. Where project.cfg
configures one site’s publish, apply.cfg declares a whole project —
its member sites, top-level functions, compute workloads, managed databases, and
tenancy schema — and reconciles it as one applied unit. It is upsert, never
prune: apply create-or-replaces only the resources it names and never deletes
anything absent from the manifest, so declarative and imperative management
coexist.
Unlike project.cfg, a missing manifest is an error (there is nothing to
apply). The default filename is apply.cfg (-f overrides it).
Manifest top-level fields
| Field | Type | Default | Description |
|---|---|---|---|
version | u32? | absent ⇒ current | Manifest schema version — see below. |
project | string? | resolved | Target project. Absent ⇒ --project / BOATRAMP_PROJECT / the default project. |
sites | list<ApplySite> | [] | Sites to publish (each an atomic content-addressed deployment; see the apply how-to). |
functions | list<ApplyFunction> | [] | Top-level functions to deploy (create-or-replace). |
compute | list<ApplyCompute> | [] | Compute workloads to create-or-replace — see compute. |
databases | list<ApplyDatabase> | [] | Declared managed databases — see databases. |
tenancy | TenancySchema? | untouched | The project’s tenant-isolation schema. Reconciled before sites/functions. Absent ⇒ the stored schema is left untouched (use boatramp tenancy clear to remove one). |
The whole document is parsed with deny_unknown_fields, so a typo or an
excluded key fails to parse rather than being silently ignored.
version + migration
The optional top-level version: <u32> opts a document into the migration
framework (v0.6.0):
- Absent (or equal to the current schema) ⇒ parsed strictly against the
current typed schema. An old-shaped manifest that omits
versionfails with an upgrade error naming the migration path (the most common cause is a pre-v0.6.0 raw-JSONcompute[].spec). version: Nolder than current ⇒ the document is run through the registered migration chain (vN → … → current) viaboatramp config migrate <file>(--writerewrites in place), then parsed strictly. An upgraded/migrated manifest omitsversion:(current = absent).version: Nnewer than this build understands ⇒ rejected.
Declare the schema you wrote against to get migration support; omit
version:and your manifest is parsed as current. Addversion: 1(the pre-v0.6.0 schema) only when upgrading an old manifest withconfig migrate.
The manifest is authored in RON. As of v0.6.5 a JSON manifest is also accepted for interop (current schema only — a JSON document is not run through the migration chain); RON stays the canonical authoring format. See the config formats how-to.
compute workloads (ComputeSpec)
Each compute[] entry is a workload name plus a typed spec (v0.6.0 — before
this, spec was a raw JSON blob; it is now the typed ComputeSpec, so a
malformed spec fails at parse time). It mirrors the server’s PutComputeRequest:
| Field | Type | Default | Description |
|---|---|---|---|
name | string | — | Workload name (project-scoped). |
spec | ComputeSpec | — | The immutable workload spec (below). |
replicas | u32 | 1 | Desired replica count. |
placement | PlacementConstraints | none | regions (list) + labels (map) a replica’s node must satisfy. |
ComputeSpec key fields:
| Field | Type | Default | Description |
|---|---|---|---|
root | RootSource | — | The workload’s root filesystem source — see RootSource below. |
kernel | string | — | Blob hash of the vmlinux kernel; applies only to a micro-VM (rootfs(…)) source, omitted otherwise. |
vcpus | u32 | — | Virtual CPUs. |
mem_mib | u32 | — | Guest memory (MiB). |
port | u16 | — | The in-guest TCP port the app listens on (the gateway targets it). |
entrypoint | list<string> | [] | The in-guest argv the init execs. |
env | map<string, string> | {} | Environment variables for the entrypoint. |
volumes | list<VolumeRef> | [] | Persistent volumes (mount / name / size_mib); opt-in (default root is read-only + ephemeral scratch). |
restart | enum | always | never (run-to-completion), on_failure, or always. |
startup_grace_secs | u32 | 30 | Window a fresh replica has to become healthy before it is treated as a broken launch. |
isolation | enum | trusted | trusted (shared-kernel container is fine) or untrusted (requires a micro-VM / managed platform). |
scale_to_zero | bool | false | Snapshot + stop when idle; cold-restore on the next request. |
writable_root | bool | false | Writable root FS instead of the hardened read-only default (honored only under the single-tenant posture). |
bindings | list<ComputeBinding> | [] | Managed resources (kind: sql, …) resolved to a tenant-scoped endpoint + credential injected into the guest env at launch. |
RootSource
A tagged, snake_case newtype variant selecting the root FS form (matched 1:1 to the backends that accept it):
| Variant | Source | Backends |
|---|---|---|
image("repo:tag") | An OCI image reference pulled from a registry. | docker, cloudflare |
tar("<blob-hash>") | A tar rootfs archive (a shared-store blob hash) staged + unpacked. | native container |
rootfs("<blob-hash>") | A rootfs block image (a shared-store blob hash) attached as the root device (paired with kernel). | firecracker micro-VM |
compute: [
( name: "api",
spec: ( root: image("ghcr.io/acme/api:1"), vcpus: 1, mem_mib: 512, port: 8080 ),
replicas: 2 ),
]
Managed databases (databases)
The databases: block (v0.6.0) is the declarative front door onto boatramp’s
managed-database provisioning — the SOLE authoring surface for a project-scoped
managed DB (there is deliberately no imperative db create; a create verb
would compete as a second source of truth). Declaring an entry no longer requires
an operator to hand-edit boatramp.cfg — a project author adds an entry and runs
boatramp apply.
Each ApplyDatabase is a typed, SAFE projection of the node-static external
database config, restricted to the fields a project author may safely declare.
Reconciled before sites/functions/compute, so a handler shipped in the same
apply binds an already-provisioned DB. It is PUT-only (create-or-replace +
eager provision); removing an entry NEVER deprovisions the database, volume,
or credential (teardown stays an explicit imperative verb — a data-loss guard).
| Field | Type | Default | Description |
|---|---|---|---|
name | string | — | Binding name — how a guest reaches it via sql.open("<name>") and the {name} key segment. |
kind | enum | — | The engine: postgres or mysql. |
version | u32? | engine default | Engine major version (e.g. 16). A change on re-apply routes through the owner-gated migrate/repair path, never a silent re-init. |
extensions | list<string> | [] | Trusted extensions to make available (Postgres). Advisory — enabling one still routes through the owner-gated migration step + operator allowlist. |
size | enum | small | Sizing preset: small / medium / large → bounded vcpus/mem/volume (NOT raw VM knobs — the disk-exhaustion guard). |
tenant | enum | single | Isolation mechanism: single (dedicated server per tenant) or shared (one server, per-tenant db + role). A change on re-apply is refused. |
tenant_scope | enum | project | Tenant grain: project or site. A change on re-apply is refused. |
read_only | bool | false | Open every transaction READ ONLY. |
rls_session | bool | false | Opt-in native-RLS session injection. |
tenant_guc | string? | — | Postgres session GUC for the host-resolved tenant (RLS backstop; honored with rls_session + Postgres). |
session_guc | string? | — | Session GUC for the anonymous session axis (RLS backstop). |
tenant_all_marker | string? | — | The reserved sentinel written to tenant_guc on an all-scoped read. |
pool_max | u32? | — | Max pooled connections. Capped to an operator ceiling (64) at lowering. |
connect_timeout_secs | u64? | — | Connection/acquire timeout. Capped (60s). |
startup_grace_secs | u32? | — | Startup grace for the managed server’s first initdb. Capped (600s). |
Excluded — the security contract. These are not fields; a manifest that
names one fails to parse (deny_unknown_fields), because the credential is
minted + sealed server-side and never lives in a committable manifest:
| Excluded field | Why |
|---|---|
image | Arbitrary-OCI RCE — boatramp always picks the stock engine image at lowering. |
password_env | Omitting it is what selects the managed-credential path; a declared DB can NEVER bring its own password. |
url_env / read_url_env / migration_url_env | BYO-secret / SSRF / arbitrary-host reach. |
path | Host-fs traversal. |
compute | The per-project server workload is DERIVED (project-qualified), never author-named — so a manifest can only provision onto its own project’s server. |
Semantics. Declaring a database mints owner-role identities, so the declare +
provision route is Project·Admin-gated (the same owner-grade placement as
migrate/repair — a project publisher/deployer can never reach it). A declared
database is persisted project-scoped at project/{project}/database/{name}
and MERGED with the node-static [handlers].bindings.sql.databases map at one
resolution point where daemon-static config WINS, fail-closed, on a same-name
conflict — a project manifest may never shadow or downgrade a node operator’s
bring-your-own binding. Two operator ceilings guard the node’s disk:
max_declared_databases (count, default 16) and max_declared_volume_mib
(aggregate volume, default 512 GiB), enforced fail-closed at declare (a 422).
Read-only inspection is boatramp db ls | get <name> | status <name>
(CLI reference); there is no db create. See the
apply how-to for the end-to-end flow.
databases: [
( name: "app", kind: postgres, version: 16, size: medium,
tenant: shared, tenant_scope: project, extensions: ["pgcrypto"],
rls_session: true, tenant_guc: "app.tenant_id" ),
]
boatramp.cfg schema
boatramp.cfg is the server config, read by boatramp serve. It is
RON. Every value can also be set as a flag or an
environment variable, which take precedence. The whole file is optional — serve
runs with defaults without it.
boatramp serve --config boatramp.cfg
Precedence for any value: flag / environment variable > boatramp.cfg >
built-in default.
Top-level sections, all optional:
| Section | Purpose |
|---|---|
serve | Bind address, data dir, auth keys, upload limits. |
security | Operator security posture (profile + per-knob overrides). |
secrets | Envelope encryption for cert private keys at rest. |
handlers | Wasm handler runtime (needs the handlers feature). |
cluster | Self-hosted Raft cluster (needs the cluster feature). |
compute | Container / microVM execution backends. |
serve
| Field | Type | Default | Description |
|---|---|---|---|
addr | socket address | 127.0.0.1:8080 | Bind address. Env BOATRAMP_ADDR. |
data_dir | path | ./data | Root for the filesystem blob + KV backends. Env BOATRAMP_DATA_DIR. |
auth_root_private_key | "<alg>:<hex>" | — | Root signing key: this node verifies and mints tokens. Env BOATRAMP_AUTH_ROOT_PRIVATE_KEY. |
auth_root_public_key | "<alg>:<hex>" | — | Root verify key: this node verifies only, cannot mint. Env BOATRAMP_AUTH_ROOT_PUBLIC_KEY. |
bootstrap_secret | string | — | Single-use secret enabling token bootstrap. Prefer the env var / flag so it is not written to disk. Env BOATRAMP_BOOTSTRAP_SECRET. |
signer | signer enum | — | External signer (KMS/HSM/Vault) in place of an in-process key. See below. |
max_upload_bytes | integer | unlimited | Reject blob uploads larger than this. |
default_site | string | — | Site served for a Host matching no domain, instead of 404. |
protect_previews | bool | false | Require a control-plane token to view /_deploy previews. |
pop_origin | string | — | The fleet’s canonical public origin (e.g. https://cp.example.com) a per-request proof-of-possession must bind (aud). Required for holder-bound (cnf/PoP) tokens; compared against the proof, never a Host/X-Forwarded-* header. Env BOATRAMP_POP_ORIGIN. See PoP-bind a token. |
blob_notify_tier | dry-run | provision | verify-only | refuse | — | Cloud blob-change notification provisioning tier for blob triggers on a cloud object store (S3→SQS / GCS→Pub/Sub / Azure→Event Grid). Absent ⇒ no provisioning (blob triggers work only on a self-watching backend like fs). See Cloud blob triggers. |
blob_notify_account_id | string | — | Scopes the provisioned notification pipeline: the AWS account id (S3 queue policy) or GCP project id (GCS topic + notificationConfig). Unused by Azure (the queue shares the account’s shared-key auth). |
s3_credential | table | — | Node-level base S3 credential sourced from the sealed [secrets] store (shared by the S3 blob backend and the AWS ingress minter). See serve.s3_credential. |
blob_fallback | table | — | A read-only secondary blob backend for a zero-downtime backend switch. See serve.blob_fallback. |
s3_ingress_addr | socket address | — | Bind for the dedicated S3-upload ingress listener; see S3 upload ingress. |
s3_ingress_secret_file | path | — | On-node HKDF root for the local S3 ingress face; see S3 upload ingress. |
s3_ingress_public_url | string | — | Public base URL a minted upload credential embeds; see S3 upload ingress. |
s3_ingress_mint_max_ttl_secs | int | 3600 | Operator ceiling on a minted upload credential’s TTL; see S3 upload ingress. |
s3_ingress_mint_max_bytes | int | — | Operator ceiling on a minted credential’s object-size cap; see S3 upload ingress. |
s3_ingress_cloud | table | — | Cloud-brokering identity for the ingress minter (upload direct to a cloud store). See serve.s3_ingress_cloud. |
Warning: with no
auth_root_*key configured, control-plane auth is disabled. Under the defaultmulti-tenantposture,serverefuses to start that way on a non-loopbackaddr. Configure a key, bind127.0.0.1, or select a looser security posture.
serve.signer
Selects an external signer so the root key never sits in process memory. Written as a RON enum. Credentials (tokens, PINs) come from the named environment variables, never this file.
| Variant | Fields |
|---|---|
Local | private_key: "<alg>:<hex>" |
Vault | address, key, token_env, alg (Es256 | Ed25519) |
AwsKms | key_id, region (optional) |
GcpKms | key_version, access_token_env |
AzureKv | vault_url, key, key_version, access_token_env |
Pkcs11 | module, token_label, key_label, pin_env, alg |
serve: ( signer: Vault(
address: "https://vault:8200",
key: "boatramp-root",
token_env: "VAULT_TOKEN",
alg: Es256,
) )
See Hold the signing key in a KMS/HSM/Vault.
serve.s3_credential (v0.6.1)
A node-level base S3 credential sourced from the sealed secrets
store instead of the ambient AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env
chain. One source feeds both consumers — the S3 blob object backend
(--blobs s3) and the AWS blob-upload cloud minter
(serve.s3_ingress_cloud) — because it is the same
bucket key. Absent ⇒ the ambient AWS env chain (unchanged, non-breaking).
| Field | Type | Description |
|---|---|---|
access_key_id | string | The AWS access key id — a public identifier, so it is plain config. Empty is refused at startup. |
secret_access_key | string | The secret access key as a reference, never the raw secret in-file: boatramp:<name> (the project-scoped sealed store, resolved under the default project via the [secrets] envelope), or env:<VAR> / a bare <VAR> (the operator’s own environment, honored only when the posture’s allow_env_secret_refs is set). Unsealed at startup; the resolved value is redacted from Debug/logs. |
serve: ( s3_credential: (
access_key_id: "tid_public_akid", // a public identifier — plain config
secret_access_key: "boatramp:tigris-secret", // a sealed secret REFERENCE, not the secret
) )
Seal the secret once with boatramp secrets set (default project), then
reference it here.
- Fail-closed: a
boatramp:/env:ref configured with no[secrets]envelope is a startup error — boatramp does not silently fall back to the ambient env chain (which would mask the misconfig). - Cluster caveat: blob storage is built before the replicated control plane,
so a
boatramp:(KV-backed) ref is refused fail-closed on a cluster node (the sealed store is single-node) — use anenv:<VAR>ref there. The AWS cloud minter is likewise single-node this release.
serve.blob_fallback (v0.6.2)
A read-only secondary blob backend enabling a zero-downtime blob-backend
switch (fs→cloud, provider→provider, region→region). While it is attached,
serving reads the primary (the [serve] blobs / s3_* / gcs_* /
azure_* fields) first and, only on a definitive miss for a boatramp-owned
key, falls through to this secondary — so there is no serving gap while the old
backend drains into the new one. A transient primary error propagates (never
serves stale/secondary bytes on a blip); the fall-through is
prefix-allowlisted to content-addressed blobs, hblob/, and mqgp/ (a
control-plane-shaped key never resurrects off the secondary); keys are forwarded
byte-identical so tenant isolation is preserved. put and delete are
primary-only — the secondary is strictly read-only, never written.
This block takes the same backend-descriptor shape as the primary — a blobs
selector plus the matching per-backend option fields — plus its own optional
s3_credential and a read timeout.
| Field | Type | Default | Description |
|---|---|---|---|
blobs | fs | s3 | gcs | azure | fs | The secondary backend — the OLD backend to fall back to. |
s3_bucket | string | — | S3 bucket (secondary blobs = s3). |
s3_endpoint | string | — | S3 endpoint URL (a MinIO/R2/Tigris endpoint) for the secondary. |
s3_region | string | — | S3 region for the secondary. |
s3_path_style | bool | false | Path-style addressing (MinIO) for the secondary. |
s3_credential | table | — | The secondary’s own sealed base S3 credential (serve.s3_credential shape). Absent ⇒ the ambient AWS env chain. |
gcs_bucket | string | — | GCS bucket (secondary blobs = gcs). |
gcs_endpoint | string | — | GCS endpoint URL (a fake-gcs-server emulator) for the secondary. |
gcs_anonymous | bool | false | Skip GCS credential resolution (anonymous — the emulator) for the secondary. |
azure_account | string | — | Azure storage account name (secondary blobs = azure). |
azure_container | string | — | Azure container name for the secondary. |
azure_access_key | string | — | Azure storage account access key (shared-key auth) for the secondary. |
azure_emulator | bool | false | Use the Azurite emulator for the secondary. |
secondary_timeout_secs | int | 5 | Bound (seconds) on each secondary read, so a wedged secondary degrades a primary miss to NotFound rather than hanging the serve path. |
serve: (
blobs: s3, // the NEW (primary) backend
s3_bucket: "acme-blobs-new",
s3_region: "auto",
blob_fallback: ( // the OLD backend to read through
blobs: fs,
secondary_timeout_secs: 5,
),
)
This is a bounded transition aid. Rollout: deploy primary = new, blob_fallback = old, drain the old backend into the new one with
boatramp blob migrate (a bare blob migrate with a blob_fallback configured
drains the fallback → primary), then remove this block and restart. While a
fallback is attached the node logs a prominent transition-mode WARNING at
startup and blob GC refuses to prune (a prune returns 409) — a union
list over a primary-only delete would otherwise reclaim a secondary-only
object that a read could resurrect. See
Switch the blob backend with zero downtime.
serve.s3-upload-ingress (v0.5.9)
The external S3 upload ingress — the daemon-config side of letting a client
outside the wasm sandbox upload directly into a project’s blob container over the
S3 protocol, which the guest then reads unchanged through wasi:blobstore. These
are the [serve]-level knobs; the guest capability, operator CLI, and per-recipe
UX are covered in Ingest large uploads over S3.
| Field | Type | Default | Description |
|---|---|---|---|
s3_ingress_addr | socket address | — | Bind for the dedicated SigV4 ingress listener — a separate listener from serve.addr with its own auth surface (it never reaches serve_by_host or the /api router). Absent ⇒ the local S3 face is not served (opt-in; a deployment that only brokers cloud credentials never needs it). |
s3_ingress_secret_file | path | — | Path to the raw 32-byte HKDF root the local face derives each credential’s secret_access_key from — distinct from the [secrets] KEK and the COSE signing key (hard domain separation). Holds a path, never key material. The same file must be present on every node in a cluster. Absent on a single node ⇒ an ephemeral per-process root; absent on a multi-node deployment ⇒ the face is refused (fail-closed). |
s3_ingress_public_url | string | derived | The publicly-reachable base URL a minted upload credential embeds (the presigned-PUT prefix, or the SDK endpoint for temp-credentials). Absent ⇒ derived from s3_ingress_addr as http://<addr> (fine for a same-host dev loop; set the TLS-terminated public URL in production). |
s3_ingress_mint_max_ttl_secs | int | 3600 | Operator ceiling (seconds) on a minted credential’s TTL — a guest/operator can only request a shorter lifetime (the mint clamps to this). 0 disables minting entirely (the binding is never attached). |
s3_ingress_mint_max_bytes | int | — | Operator ceiling (bytes) on a minted credential’s max_bytes — a guest can only request a smaller cap. Absent ⇒ no host-side clamp (the per-container face ceiling still applies). |
serve: (
addr: "0.0.0.0:8080", // control plane / site edge
s3_ingress_addr: "0.0.0.0:9000", // the dedicated S3 face
s3_ingress_secret_file: "/etc/boatramp/s3-ingress.key",
s3_ingress_public_url: "https://uploads.example.com",
s3_ingress_mint_max_ttl_secs: 3600, // TTL ceiling (default 1h)
s3_ingress_mint_max_bytes: 104857600, // per-cred object cap (100 MiB)
)
When the node’s blob backend is a cloud object store, add
serve.s3_ingress_cloud so the mint brokers a native,
scoped, short-lived cloud credential and the client uploads directly to the
real store (bytes never transit the node) instead of the local face.
serve.s3_ingress_cloud (v0.5.9)
Cloud-brokering identity for the upload minter. Only the fields for the active blob backend are consulted; absent ⇒ the local S3 face mints. See Ingest large uploads over S3 — per-cloud setup.
| Field | Type | Default | Description |
|---|---|---|---|
aws_role_arn | string | — | AWS: the IAM role ARN the base credential assumes (sts:AssumeRole, the default) to broker the scoped session-policy credential. |
aws_use_federation_token | bool | false | AWS: use sts:GetFederationToken instead of AssumeRole (an IAM-user base credential, not itself a session). |
gcs_client_email | string | ADC | GCS: the service-account client email whose V4 signed URLs / IAM-signed uploads the minter produces. Absent ⇒ resolved from ADC. |
azure_account | string | blob-arg | Azure: the storage account name (SAS signature + blob URL). Absent ⇒ taken from the azure_account blob-backend arg. |
azure_service_url | string | derived | Azure: the blob service URL (https://{account}.blob.core.windows.net/). Absent ⇒ derived from the account name. |
azure_hns | bool | false | Azure: declare the account has a hierarchical namespace (HNS/ADLS-Gen2). A directory-scoped SAS only confines to a sub-prefix on an HNS account, so a prefix mint is refused unless this is true (fail-closed). A single-key mint is unaffected. |
security
The operator security posture: a profile preset plus per-knob overrides. Absent
means the strict multi-tenant default. This section is operator-only — it is
never part of site config, so a site writer cannot relax it. Inspect the resolved
posture with boatramp security explain.
| Field | Type | Default | Description |
|---|---|---|---|
profile | string | multi-tenant | multi-tenant (strict), single-tenant (one trusted operator), dev (loopback-loose), or a name from profiles. |
overrides | knob table | — | Individual knobs; a knob is the source of truth, a profile is sugar. |
profiles | map | — | Custom named profiles, each a set of overrides over the strict baseline. |
projects | map | — | Per-project overrides of the four tenancy/capability sub-knobs — see Per-project posture. |
Override knobs (byte caps: 0 = unlimited):
| Knob | Description |
|---|---|
allow_unauthenticated_public_bind | Permit a non-loopback bind with auth off. |
max_upload_bytes | Blob upload cap. |
allow_site_unix_upstreams | Let a site’s gateway target unix: sockets. |
allow_site_private_upstreams | Let a site’s gateway target private IPs. |
allow_guest_private_egress | Let a handler guest’s outbound wasi:http reach private/loopback IPs. Off under multi-tenant (the SSRF default — guests reach only public hosts); on under single-tenant/dev. A guest calling its own site or a sibling function uses the capability-gated invoke binding instead, which is unaffected by this knob. |
allow_guest_self_egress | Let a handler guest’s outbound wasi:http reach this instance’s own serve socket (loopback on the serve port) even when allow_guest_private_egress is off — a much tighter grant, exposing only boatramp’s own front door (which re-applies host routing + auth + rate-limit). Self-recursion is depth-capped. On by default in every posture. |
max_handler_blob_bytes | Per-handler blobstore write cap. |
max_component_bytes | Wasm component size cap. |
oidc_require_audience | Require an aud claim on OIDC exchange. |
domain_verify_allow_private | Allow domain-verification probes to private hosts. |
domain_verify_self_serve | Serve pending HTTP ownership challenges from the edge (before host routing) so an unattached host can verify itself. On by default; disable to require out-of-band token placement. |
allow_shared_kernel_compute | Permit container (shared-kernel) compute; off ⇒ microVM only. |
ratelimit_fail_open | Serve rather than reject if the rate-limit store is unavailable. |
allow_implicit_routing | Resolve an unmatched host to a site without a registered domain (first-label <site>.host / sole site). Off under multi-tenant; a loopback bind enables it regardless. See addressing. |
require_pop | Require every control-plane token to be holder-bound (cnf) and present a valid per-request proof-of-possession. Off by default (a cnf token always requires a proof regardless; this knob additionally bans plain bearer tokens fleet-wide). Needs pop_origin set. See PoP-bind a token. |
require_domain_verification | Refuse to serve a non-local Host that isn’t a verified, attached virtualhost — the request gets the “verification pending” holding page instead of any default_site/implicit fallback. On under multi-tenant/single-tenant, off under dev (which serves arbitrary local test hosts). Local hosts (localhost/*.localhost/*.local/IP literals) always serve. Disable it fleet-wide here, or exclude one host with domain add <host> --unverified. |
allow_compute_exec | Permit boatramp compute exec — running a command inside a running workload (docker-exec style), i.e. arbitrary code execution in the workload. Off in every profile but dev; opt in for migrations/backups/debug. Container + docker backends only. |
allow_env_secret_refs | Permit a handler’s / function’s secrets map to name a bare / env:-scheme reference into the serve process’s own (the operator’s) environment. Off under multi-tenant (an untrusted config author could exfiltrate any host env var — another tenant’s DB password, a cloud key), on under single-tenant/dev. When off, such a reference is refused fail-closed. |
allow_guest_email | Permit a guest handler/function’s email capability to actually send. Off under multi-tenant (an untrusted tenant can’t use the shared node’s SMTP egress), on under single-tenant/dev. When off the send verb is absent and returns access-denied. Independent of the guest-HTTP egress knobs; the SMTP relay host is still held to the SSRF rule. |
allow_guest_mint_capability | Permit a guest’s capability capability to mint fleet-signed target-capability tokens. A minted token’s audience is host-forced to the guest’s own project and its TTL clamped to max_guest_capability_ttl_secs. Off under multi-tenant, on under single-tenant/dev. When off the mint verb is absent and returns access-denied. |
max_guest_capability_ttl_secs | Operator ceiling (seconds) on a guest-minted capability’s TTL; a mint requesting more is clamped to this. 0 disables minting outright. Default 900 (multi-tenant) / 3600 (single-tenant/dev). |
allow_guest_admin_domains | Permit a guest’s admin capability (admin:domains import) to manage the project’s domains (add/verify/attach-verified/remove) via boatramp:handlers/admin. Off under multi-tenant, on under single-tenant/dev. Domain attach still runs the real ownership probe; there is no guest path to the unverified-attach route. |
allow_guest_admin_email | Permit a guest’s admin capability (admin:email) to manage the project’s SMTP email profiles (set/delete). Passwords stay sealed, never returned to the guest. Off under multi-tenant, on under single-tenant/dev. |
allow_guest_admin_site | Permit a guest’s admin capability (admin:site) to write the project’s site config + aliases (routing, headers, cache). A config write can’t attach an unverified domain. Off under multi-tenant, on under single-tenant/dev. |
allow_guest_admin_secrets | Permit a guest’s admin capability (admin:secrets) to write the project’s sealed secrets (set/rotate/delete — write-only, redacted). The most sensitive admin surface; an operator can withhold it while still allowing domains/email/site. Off under multi-tenant, on under single-tenant/dev. |
require_tenancy_declaration | Require every function/handler that opens a sql/orm database to make an explicit in-site tenancy decision (disabled or scoped) — an undeclared importer is refused at activation, so serving a database unscoped is always a reviewed choice, never an accidental omission. On under multi-tenant, off under single-tenant/dev (which treat undeclared as disabled). See Isolate tenants within a project. |
allow_cross_tenant_db | Permit a function/handler to declare a cross-tenant (all) read/write access mode — reaching every tenant’s rows in a shared database. Off under multi-tenant (an all mode is capped down to own, so no guest can read across tenants even if it asks), on under single-tenant/dev. See Isolate tenants within a project. |
security.projects (per-project posture, v0.4.7)
A [security.projects.<project>] block overrides the four tenancy/capability sub-knobs for one
project only, layered over the resolved fleet posture. It lets a single serve process host a
strict-isolation project beside a looser one on a shared, multi-project machine.
security: (
profile: "multi-tenant", // the fleet default
projects: {
"acme-preview": ( // looser, just this project
allow_cross_tenant_db: true,
allow_guest_mint_capability: true,
),
},
)
| Per-project knob | Description |
|---|---|
require_tenancy_declaration | Override the fleet require_tenancy_declaration for this project. |
allow_cross_tenant_db | Override the fleet allow_cross_tenant_db for this project. |
allow_guest_mint_capability | Override the fleet allow_guest_mint_capability for this project. |
max_guest_capability_ttl_secs | Override the fleet max_guest_capability_ttl_secs (the mint TTL ceiling) for this project. |
Only these four in-project knobs are per-project-overridable; every other knob (egress, upload caps,
domain verification, guest-admin surfaces, …) stays fleet-wide. Each Some field of the override
wins; the rest fall through to the fleet posture, so the override composes with the global one.
Cross-project isolation is structural (project = database) — never a knob, so a per-project
override can only tune that project’s own in-project strictness and its guests’ capability-mint
ceiling, never its reach into another project. These four knobs are also BOATRAMP_SECURITY_*
env-settable at the fleet level (see env.md); per-project overrides
are config-file only.
See Choose & inspect a security posture and The security posture model.
secrets
Envelope-encrypt cluster-managed certificate private keys so they are never cleartext in the replicated control plane. Absent means keys are stored cleartext.
| Field | Type | Description |
|---|---|---|
envelope | string | local (machine-local AES-256-GCM KEK) or vault (Vault Transit). |
kek_file | path | Local KEK file (auto-generated 0600). In a cluster the same file must be on every node. |
vault | table | For envelope: "vault": addr, key (a Transit key), token_env. |
handlers
Wasm handler runtime. Parsed always, consumed only with the handlers feature.
| Field | Type | Default | Description |
|---|---|---|---|
pooling | bool | false | Use the wasmtime pooling allocator (faster instantiation, large virtual-memory reservation). |
sync_max_timeout_ms | int | 10000 | Safety-max wall-clock for a connection-bearing invocation (a site handler or a synchronous function/webhook invoke). A route/function may declare a lower timeout, never a higher one. Kept tight: a client + proxy + the shared request pool block while it runs. |
async_max_timeout_ms | int | 900000 | Safety-max for a durable async invocation — the drain running ?mode=async calls, workflow steps, cron/queue/blob triggers, and messaging consumers. No client is connected and the work is retried + dead-lettered, so this can be far larger (default 15 min). Runs on its own concurrency budget, so a long job never starves live traffic. |
async_max_concurrency | int | 8 | Max concurrent in-flight async-lane invocations — a pool separate from (and smaller than) the request pool, so a burst of long background jobs can’t exhaust the slots live site traffic needs. |
async_max_fuel | int | — | Optional CPU fuel ceiling for an async-lane invocation. A large async timeout bounds only wall-clock; pair it with a fuel bound to keep a CPU-bound guest from spinning the whole window. Omit ⇒ unmetered. |
async_max_memory_mb | int | 64 | Linear-memory ceiling for a durable async invocation, in MiB. The async lane is where heavy, retryable, no-client-connected work belongs (image decode/resize, PDF/thumbnail, document processing), so this is the knob to raise for memory-hungry workers. Raise it only as high as the heaviest async component needs — see the memory-ceiling note below. |
sync_max_memory_mb | int | 64 | Linear-memory ceiling for a connection-bearing invocation, in MiB. Kept tight by default (a client + proxy + the shared request pool block while it runs); raise it only if a synchronous handler genuinely needs more. |
streaming_max_memory_mb | int | 64 | Linear-memory ceiling for a long-lived streaming invocation (SSE / chunked / token streaming), in MiB. |
messaging_max_unflushed_msgs | int | 0 | Relaxed messaging-publish durability (opt-in). 0 (default) = strong: publish() returns only after the message is crash-durable — stronger than NATS JetStream’s default sync publish. N > 0 fast-acks publishes from the in-memory buffer (≈tens of µs vs ≈one flush interval), forcing a durable checkpoint every N messages, so at most N acknowledged-but-unflushed messages are lost on a process crash / OOM / SIGKILL / power loss. Affects ONLY the bus publish path (control-plane, auth, and consumer ack/redelivery durability are unaffected); single-node only. The loss window is bounded by both N and the store’s flush_interval (the background WAL-flush timer, ~5 ms) — so the effective steady-state bound is min(N messages, one flush_interval), and on a low flush_interval a large N rarely binds. A node with N > 0 logs a startup warning. See Publish durability. |
outbound_timeout_ms | int | — | Optional ceiling on a guest’s outbound wasi:http call (connect + first-byte), independent of the invocation timeout, so a hung upstream is bounded on its own terms. The streaming (between-bytes) timeout is left at the default so a slow token stream isn’t cut. Omit ⇒ wasmtime default. |
bindings.sql | table | — | The sql host binding. Omit for single-node (a per-site embedded libsql file); set url for a shared sqld. |
bindings.sql fields: dir, url, admin_url, replica_url, token_env,
admin_token_env, preview_mode (empty | branch | shared),
preview_init, databases. See
Use handler bindings.
Memory ceilings per lane
Every lane defaults to a 64 MiB per-component linear-memory ceiling. The
*_max_memory_mb knobs raise that ceiling for a lane; the ceiling is the maximum
any component on the lane may use, not a per-component allocation. A component
right-sizes itself down from the ceiling with its own limits.memory_mb
(per-function) or the site’s max_memory_mb; a component with neither inherits
the lane ceiling. So the model is: set the lane ceiling high enough for the
heaviest component, and let each component cap itself lower where it should. A
per-component value above the lane ceiling is clamped to it — a component can
never raise its own memory above the operator-set lane ceiling (fail-closed,
operator-gated). Leaving every knob unset keeps the historical 64 MiB everywhere.
Cost: without pooling there is no up-front reservation — each invocation
sizes its store to its effective ceiling on demand. With pooling = true the
allocator reserves roughly max-lane-memory × total-slots of virtual address
space up front (slots = the sum of the three lanes’ concurrency), so a raised
ceiling enlarges that reservation; the node logs the estimate at startup and
fails loudly if the reservation can’t be made, rather than OOM-ing on the first
invocation. Prefer raising only async_max_memory_mb (the durable lane) for heavy
jobs, so the tighter sync/streaming lanes don’t inflate the reservation.
A component that exceeds its effective ceiling at runtime no longer dead-letters
as an opaque trap: the terminal outcome and the /metrics
boatramp_handler_invocations_total{outcome="out-of-memory"} counter read
out-of-memory, so memory exhaustion is distinguishable from a logic crash.
External SQL databases
bindings.sql.databases is a map of name → external database, each a
Postgres/MySQL a guest opens by that name (sql.open("<name>")) instead of a
per-site libsql one. Needs the sql-postgres / sql-mysql build feature.
Isolation is the operator’s — such a database is shared across every guest
granted the sql binding — so it bypasses the per-site libsql boundary; libsql
stays the managed default. A name here shadows the same name on the libsql
default.
Each database has one of two sources, mutually exclusive:
- Bring-your-own (
url_env) — you run the database anywhere; boatramp reads its connection URL from an env var. - Compute-backed (
compute) — the database is a compute workload boatramp runs (seecompute). boatramp resolves the workload’s live endpoint on demand and builds the connection, so there is no URL to hand-map and it follows the workload across restarts. Withpassword_envset you bring the credential; omit it and boatramp fully manages the credential — it generates a strong password once, seals it with thesecretsenvelope, injects it into the DB workload’s server-init env at launch, and connects the handler with it, so you set no DB secret at all. A managed database therefore requires a[secrets]envelope (it refuses to store a credential it cannot seal) and a persistent volume on the DB workload (so the password the server was initialized with survives a restart).
| Field | Type | Default | Description |
|---|---|---|---|
kind | string | — | Engine: postgres (aliases postgresql/pg) or mysql (alias mariadb). Required. |
url_env | string | — | Bring-your-own source. Env var holding the connection URL, e.g. postgres://user:pw@host/db. A secret — never the URL in-file. Required unless compute is set. |
read_url_env | string | — | Env var holding a read-replica URL. When set, open-read-only routes there; writes stay on url_env. |
compute | string | — | Compute-backed source. Name of a compute workload (a Postgres/MySQL boatramp runs) to source this database from. Mutually exclusive with url_env. |
database | string | — | Compute-backed: the database name inside the server (non-secret). Required with compute. |
user | string | — | Compute-backed: the connecting user (non-secret). Required with compute. |
password_env | string | — | Compute-backed: env var holding the password for user. Omit to let boatramp generate + manage the credential (needs [secrets]); set it to bring your own. |
pool_max | int | 8 | Maximum pooled connections. |
read_only | bool | false | Open every transaction READ ONLY (the engine rejects writes). |
allow_preview | bool | false | Permit preview deployments to reach it. Default refuses them, so a preview can’t touch live external data. |
connect_timeout_secs | int | 10 | Connection/acquire timeout, in seconds. |
cluster
Self-hosted Raft cluster. Parsed always, consumed only with the cluster
feature. The peer mesh runs over RFC 7250 raw-public-key mutual TLS. A cluster is
defined by its root of trust — there is no peer map; nodes self-identify and
join by redeeming a ticket.
| Field | Type | Default | Description |
|---|---|---|---|
listen | socket address | — | Bind for the Raft peer mesh (distinct from serve.addr). |
root_pubkeys | list of strings | serve.auth_root_public_key | The cluster root anchor set (es256:/ed25519: hex). Every join/trust decision verifies against it. A set enables make-before-break root rotation. |
seeds | list of strings | — | Control-plane addresses of existing members. Present ⇒ this node joins; absent + --cluster-init ⇒ it founds. |
join_token | string | — | The single-use bearer join token used when seeds are set. Keep the secret out of the file: env:VAR, path:/file, or an inline literal. |
store_dir | path | <data-dir>/raft | This node’s durable Raft store. Never shared between nodes. |
mesh | table | — | Mesh identity + TLS: key_file, key_rotation, join_token_ttl, gate_client_writes. |
The node id is derived from the node’s mesh key — there is no node_id
field. Founding and joining are driven from the command line: serve --cluster-init founds a new cluster, serve --cluster-join <ticket> joins one
(from cluster add). The old static-genesis fields (node_id, peers,
voters, bootstrap) have been removed.
Warning: a non-loopback
listenrefuses to start with an empty trust set (found with--cluster-initor join with--cluster-join <ticket>). Never point two nodes at onestore_dir.
See Deploy a self-hosted cluster and Mesh identity & the single root anchor.
compute
Container / microVM execution backends. Present ⇒ this node advertises compute
capacity to the scheduler; backends are capability-detected: the native
container backend on Linux; the KVM microVM (vmm-embedded) where /dev/kvm
exists; the macOS-native microVM (vmm-vz) on Apple silicon + macOS 15+,
which boots each replica as a Linux VM via Virtualization.framework (strong
per-VM isolation, the same user surface as the KVM backend — no config change);
and remote docker wherever a Docker daemon is reachable. macOS 26 is
recommended for the vmm-vz backend: macOS 15’s vmnet cannot do
container-to-container networking, so multi-replica cross-VM comms needs 26
(single-node serve works on 15). Nothing in the spec, CLI, or the fields below
differs by backend — the environment difference lives behind the backend.
| Field | Type | Default | Description |
|---|---|---|---|
bridge | string | br-boatramp | Bridge the guest veths / VM taps attach to. |
subnet | string | 10.0.0.0/24 | Guest IP subnet. |
vcpus | integer | detect | vCPUs this node advertises as schedulable (0 = detect). |
mem_mib | integer | 1024 | Memory (MiB) advertised as schedulable (0 = 1 GiB). |
sql_shim_url | url | — | Guest-reachable base URL of the compute sql-shim — set ⇒ a workload’s --bind sql reaches the managed database through a listener bound on 0.0.0.0:<port>. Use the address the guest reaches the host at: the compute bridge gateway for the native container backend (http://10.0.0.1:8081), the docker bridge gateway for rootful docker (http://172.17.0.1:8081), or http://host.containers.internal:8081 for rootless podman. None ⇒ compute sql bindings off. |
docker_endpoint | published | bridge | published | How the remote-Docker backend reports a workload’s reachable endpoint. published publishes the container port on 127.0.0.1:<ephemeral> and routes there, so a host-native serve reaches it on any daemon — including Docker Desktop / macOS, where the container bridge IP is not host-routable. bridge routes to the container bridge IP directly; only reachable when serve shares the daemon’s network (e.g. serve itself runs in a container on the same Docker bridge). |
docker_volume_mode | named | bind | named | How the remote-Docker backend backs a workload’s persistent volumes. named attaches a daemon-managed docker volume by name (portable — works with a remote daemon and Docker Desktop / macOS). bind bind-mounts a host directory under <data_dir>/compute/volumes/<name> (matches the native-container layout, local daemon only). Docker volumes are node-local and outside the blob-snapshot durability story (consistent with the docker backend’s no scale-to-zero); named volumes survive restarts but not cross-node migration. |
region | string | — | This node’s region tag (FA-8). Advertised on the node so a gateway routing to a compute:-backed workload with --lb nearest sends each request to the nearest replica by its node’s region — no manual --region map. See Route to the nearest region. |
kernel_signing_pubkeys | list | boatramp’s built-in key | Static trust anchors ("<alg>:<hex>") for the strict-posture kernel bar; a signed default kernel must verify against one. |
kernel_allowed_hashes | list | the released boatramp-vmlinux hash | Static allow-list of kernel content hashes a dynamic default may select under multi-tenant. Ships pre-seeded with the first-party signed release so it verifies out of the box; replace it to allow only your own kernels. |
internal_dns | bool | true | Run the per-project internal DNS resolver on the bridge gateway so a container resolves a sibling workload — or its managed DB — by name within its project. Every container’s /etc/resolv.conf is pointed at the gateway; resolution is source-IP-scoped (a tenant sees only its own project’s names). Linux + container backend only. See Reach a sibling workload by name. |
dns_upstream | string | 1.1.1.1:53 | Upstream resolver (host:port) the internal DNS forwards external names (and anything outside a project’s namespace) to. |
dns_domain | string | boatramp.internal | The internal DNS suffix names live under: a workload web in project acme answers to both bare web and web.acme.boatramp.internal. |
The kernel-signing keys and hash allow-list are static (host-access-gated) trust
anchors — the fleet default kernel itself is a
dynamic setting (compute.default_kernel), changeable
without a restart but verified against these anchors at boot. See
Run a container or microVM.
Note: vcpus, mem_mib, and the default kernel are also settable at runtime via
boatramp config — the boatramp.cfg values are the
baseline a dynamic override layers over.
Dynamic daemon config
boatramp splits its configuration into two tiers by change class:
restart— the trust anchors and listener shape inboatramp.cfg. Editing them needs a process restart; that is deliberate (see The configuration model).dynamic— operational knobs stored in the control-plane KV, changed withboatramp config. A write converges fleet-wide without a restart — one node’s change replicates to every node (Raft cluster, shared store, or a SIGHUP), so there is no per-node file edit or rolling restart.
The effective config is file baseline ⊕ dynamic overrides. An unset dynamic key
falls back to the boatramp.cfg value.
Setting dynamic config
boatramp config set default_site blog # one key, converges everywhere
boatramp config get # the active config + its generation
boatramp config list # the settable keys
boatramp config rollback # revert to the previous generation
boatramp config apply -f daemon.json # replace the whole dynamic config
Every write is validated on the server before it commits, so a bad value is
rejected once (a 400) rather than converging a broken config to the fleet. Each
committed config has a generation hash; every node reports it at /healthz
(ok gen=<hash>) so you can confirm convergence.
Addressing a restart-class key with config set fails with a clear pointer to
boatramp.cfg — the old “edit the file, send SIGHUP, nothing happens” trap can’t
occur.
Dynamic keys
| Key | Type | Meaning |
|---|---|---|
default_site | string | Catch-all site for an unmatched Host. |
protect_previews | bool | Require a token to view /_deploy previews. |
max_upload_bytes | int | Blob-upload cap (bytes). Clamped by the posture ceiling. |
upload_idle_timeout_secs | int | Abort an upload stalled this long. |
max_concurrent_uploads | int | Cap simultaneous uploads. |
cluster_rate_limit | bool | Rate-limit via the shared KV instead of per-node. |
compute.vcpus | int | Advertised schedulable vCPUs. |
compute.mem_mib | int | Advertised schedulable memory (MiB). |
compute.default_kernel | KernelRef | Fleet default microVM kernel (see below). |
console.enabled | bool | Serve the embedded web console. |
console.host | string | Host the console answers on (*, an exact host, or *.suffix). |
console.path | string | URL path prefix it mounts at (default /_console). |
mcp.enabled | bool | Serve the HTTP /mcp endpoint (default on; a live kill-switch — false makes it 404). |
posture.oidc_require_audience | bool | Tighten-only: require an OIDC audience. |
posture.ratelimit_fail_open | bool | Tighten-only: set false to fail closed. |
posture.allow_shared_kernel_compute | bool | Tighten-only: set false to forbid shared-kernel compute. |
Ceilings and the tighten-only ratchet
Two safety rules make these knobs safe to expose at runtime:
- Numeric caps are clamped by a static ceiling. A dynamic
max_upload_bytesmay only lower the effective cap relative to theboatramp.cfgposture — it can never raise it (and0= unlimited is unreachable dynamically unless the static ceiling is also0). A value over the ceiling is rejected. - Posture knobs are tighten-only. A
posture.*override may move a knob only toward the safe value (harden a running fleet, e.g. during an incident). A value that would loosen it is rejected — loosening always requires the static file + a restart. This preserves the invariant that a runtime compromise can never relax the security posture.
compute.default_kernel (KernelRef)
A microVM that omits its own kernel boots this fleet default. It is a JSON object:
{ "source": "<blob-hash-or-url>", "sha256": "<content hash>", "sig": "<hex sig>" }
The kernel is verified before boot, scaled by the posture — see Run a container or microVM. Set it with:
boatramp config set compute.default_kernel '{"source":"…","sha256":"…","sig":"…"}'
Cluster convergence
A dynamic write commits on the leader and replicates by the normal control-plane
path, and every node reloads on the change notification (a Raft apply, a
shared-store changelog event, or a SIGHUP) — there is no polling. Confirm every
node converged by checking they all report the same /healthz generation.
Routing config schema
The routing section of project.cfg is the deploy-scoped config tier. It
is authored in RON, parsed at sync, and folded
into the immutable deployment manifest — so it is atomic with the content and
rolls back with it. Every field is optional; an empty routing: () is all
defaults.
Validate it without publishing:
boatramp validate
project.cfg: routing OK (2 redirects, 1 handler)
Top-level fields
| Field | Type | Default | Description |
|---|---|---|---|
version | u32 | 1 | Schema version, pinned at 1. |
index | list<string> | ["index.html"] | Directory-index candidates, tried in order. |
clean_urls | bool | false | Map extensionless URLs to .html (/about → /about.html). |
case_insensitive | bool | false | Match paths case-insensitively against redirects, rewrites, and files. |
trailing_slash | enum | Preserve | Trailing-slash policy — see below. |
error_documents | map<u16, string> | {} | Status code → error document (404: "/404.html"). |
redirects | list<Redirect> | [] | Redirect rules, first match wins. |
rewrites | list<Rewrite> | [] | Internal-rewrite or reverse-proxy rules, first match wins. |
headers | list<HeaderRule> | [] | Response-header rules; every matching rule applies, in order. |
cache | CacheConfig | — | Default Cache-Control — see below. |
mime_overrides | map<string, string> | {} | Extension → MIME override (".webmanifest": "..."). |
proxy_allow | list<string> | [] | Allowed upstream hosts for proxy rewrites — see below. |
handlers | list<HandlerConfig> | [] | WebAssembly request handlers, matched after redirects, before static lookup. |
consumers | list<ConsumerConfig> | [] | Message-consumer components, invoked per message on a topic. |
crons | list<CronConfig> | [] | Scheduled handler invocations. |
streams | list<StreamConfig> | [] | Host-level SSE / WebSocket endpoints fanning out topics. |
sessions | list<SessionConfig> | [] | Duplex, resumable session routes served by a guest session-handler — see sessions. |
Pattern fields (from, matches, handler route) use the
path matcher syntax and are compiled at validate/sync, so a bad
pattern fails at deploy time rather than at request time.
trailing_slash
| Value | Effect |
|---|---|
Preserve | Leave the path as-is (default). |
Always | Redirect to add a trailing slash. |
Never | Redirect to strip a trailing slash. |
redirects
Each rule redirects a matching path. First match wins.
| Field | Type | Default | Description |
|---|---|---|---|
from | pattern | — | Source path pattern. |
to | string | — | Destination, with :name / :splat substitution. |
status | u16 | 308 | HTTP status. 308 is permanent and method-preserving. |
when | string | — | Optional condition — the rule fires only if it and from both match. |
redirects: [ (from: "/old/:slug", to: "/new/:slug", status: 301) ],
rewrites
A rewrite serves a different resource without changing the URL. An internal
to (a path) rewrites; an absolute-URL to reverse-proxies to that upstream.
First match wins.
| Field | Type | Default | Description |
|---|---|---|---|
from | pattern | — | Source path pattern. |
to | string | — | Internal path or absolute proxy URL, with :name / :splat substitution. |
status | u16 | 200 | Status served for an internal rewrite (e.g. 200 for SPA fallback). |
when | string | — | Optional condition — the rule fires only if it and from both match. |
An SPA fallback is a rewrite of everything to the app shell:
rewrites: [ (from: "/*", to: "/index.html", status: 200) ],
Proxy rewrites are constrained by proxy_allow.
Conditional rules (when)
A redirect or rewrite may carry a when condition — a small server-side
expression over the request. The rule fires only when its from pattern matches
and its when is true; otherwise the router keeps looking. This is how you do
language- or file-aware routing without a WASM handler, and it runs in the routing
hot path (compiled once at sync, then a fast in-memory evaluation per request).
routing: (
redirects: [
// Send the root to the visitor's preferred locale.
( from: "/", to: "/fr/", status: 302, when: "prefers_language(['fr','en']) == 'fr'" ),
( from: "/", to: "/en/", status: 302, when: "prefers_language(['en','fr']) == 'en'" ),
// Fall back to the English page when a localized file is missing in this deploy.
( from: "/fr/*", to: "/en/:splat", status: 302, when: "!file_exists(path)" ),
],
)
The expression language is a subset of CEL — boolean
expressions only, no loops, no timestamps, no regex — so it is bounded and cheap.
It is compiled and type-checked at boatramp validate / sync (a bad expression
fails the deploy).
Variables (strings): method, host, path (the normalized request path).
Functions:
| Call | Result | Notes |
|---|---|---|
header("name") | string | Request header value ("" if absent). Name must be a literal. |
cookie("name") | string | Cookie value ("" if absent). |
query("name") | string | Query-string value ("" if absent). |
file_exists("/path") | bool | Does that path serve a file in this deployment (honors clean-URLs + index)? |
accepts_language("fr") | bool | Does Accept-Language accept the tag (primary-subtag match)? |
prefers_language(["fr","en"]) | string | The first listed tag the request accepts, else "". |
Operators: == != in && || !, string concatenation with +, and the
string/list methods .startsWith(…), .endsWith(…), .contains(…).
when: "method == 'GET' && header('X-Country') == 'IT' && path.startsWith('/shop')"
Computed destinations (${…})
A to destination may embed ${<expr>} — a string-valued expression from the same
language — so one rule can route to a computed target. ${…} interpolation runs
before the usual :name / :splat capture expansion. The classic use is sending
a visitor to their negotiated locale in a single rule:
redirects: [
( from: "/",
to: "/${prefers_language(['fr','en','de'])}/",
status: 302,
// Only redirect when a supported locale is actually accepted (else "//").
when: "prefers_language(['fr','en','de']) != ''" ),
],
Embedded expressions are type-checked at validate/sync (each must be a string),
and — like conditions — a ${…} that reads a header/cookie/Accept-Language
contributes to the response Vary.
Caching. A condition that reads
Accept-Language, a cookie, or a header makes the response depend on that dimension, so boatramp automatically adds the matchingVaryheader (e.g.Vary: accept-language) to the response — a downstream cache then keys on it and never serves one visitor’s locale redirect to another. Conditions that read only the URL + deploy content (path,file_exists) add noVary.
headers
Each rule sets or removes response headers on matching paths. All matching rules apply, in order.
| Field | Type | Description |
|---|---|---|
matches | pattern | Path pattern (named matches because for is a keyword). |
set | map<string, string> | Headers to set. |
unset | list<string> | Header names to remove. |
headers: [ (matches: "/assets/*", set: { "Cache-Control": "public, max-age=31536000, immutable" }) ],
cache
| Field | Type | Description |
|---|---|---|
default | string? | Default Cache-Control for responses not covered by a header rule. |
proxy_allow
Upstream hosts a proxy rewrite may target. An entry is an exact host or a
.suffix for a subtree (.internal.example.com). When the list is empty,
proxying to any public host is allowed; private, loopback, and link-local
addresses are always blocked as an SSRF guard, regardless of this list. To proxy
to a private address, declare a gateway upstream
instead.
handlers
A WebAssembly handler bound to a route. Matched after redirects, before static lookup. See Deploy a handler.
| Field | Type | Default | Description |
|---|---|---|---|
route | pattern | — | Route pattern. |
methods | list<string> | [] (all) | HTTP methods answered (GET, POST, …). |
component | string | — | Path to the component .wasm within the deployment. |
imports | list<string> | [] | Requested capabilities — see imports. |
streaming | bool | false | A streaming handler: the guest writes its response body incrementally (SSE, chunked, agent token streaming) via a #[handler(stream)] body. Served on the isolated streaming lane (its own concurrency budget + a much larger wall-clock), so a long-lived stream never holds a fast-request slot. |
limits | HandlerLimits | — | Optional resource caps, intersected with the site caps at activation. |
env | map<string, string> | {} | Static environment variables. Never secrets — a credential-shaped value is rejected at validate; use [handlers].secrets in boatramp.cfg for those. |
invoke_targets | list<string> | [] | Function names this handler may call via the invoke import — see invoke_targets. |
upload_containers | list<string> | [] | Blob containers this handler may mint an S3 upload credential for via a blob-upload import — see upload_containers. Deny-by-default (empty ⇒ mint nothing). |
tenancy | Tenancy? | None (inherit site) | Per-handler in-site tenancy decision for this route, overriding the site-level ceiling. Absent ⇒ inherit the site decision. When present it must narrow within the site ceiling — a widening is refused fail-closed at bind. Same canonical RON shape as the site’s, e.g. (mode: "scoped", column: "tenant_id", sources: [(kind: "token", claim: "tid")], read: "own", write: "own"). A scoped tenancy may add exceed_site_ceiling: true to deliberately exceed the ceiling (an authorized inline exception) — takes effect only when the site sets allow_ceiling_exceptions and (for all) the operator posture permits cross-tenant; otherwise the deploy is refused with a message naming the route. |
token_claims | HandlerGraphqlTokenClaims? | None (inherit) | Per-handler JWKS/issuer config verifying the app bearer for a token tenant source, overriding the site’s [handlers.graphql.data].claims_from_token. Only consulted when this handler’s (or the inherited) tenancy names a token source. Fields: issuer, jwks_env/jwks_url, optional audience. |
imports
The capability vocabulary a handler may request. An unrecognized import is rejected at validate.
| Import | Grants |
|---|---|
invoke | Call sibling functions by name, gated by invoke_targets. |
graphql | Run a GraphQL operation against the project’s supergraph (graphql::run), propagating the caller’s resolved principal to sub-fetches. |
email | Send mail through a per-project SMTP profile (boatramp:handlers/email). Gated by the allow_guest_email posture knob — off under multi-tenant. |
capability | Mint fleet-signed target-capability tokens (boatramp:handlers/capability). Gated by the allow_guest_mint_capability posture knob — off under multi-tenant. |
blob-upload:write / blob-upload:multipart | Mint a scoped S3 upload credential (boatramp:handlers/blob-upload) so a client uploads a blob directly, outside the sandbox. Gated by the per-handler upload_containers allowlist (empty ⇒ deny-all); the project + site are host-forced from the resolved invocation scope. :write mints a single-object presigned PUT / temp credential; :multipart additionally permits multipart uploads. A bare blob-upload or blob-upload:* wildcard is rejected. See S3-compatible blob ingress. |
wasi:http | Outbound HTTP. |
wasi:keyvalue | Per-site KV store. |
wasi:blobstore | Per-site blob store. |
wasi:messaging | Publish / subscribe on topics. |
sql | The default per-site SQL database (managed libsql), opened as sql.open(""). |
sql:<name> | A specific operator-configured named database (e.g. sql:analytics), opened as sql.open("<name>") — its own connection + role, for least-privilege isolation. |
sql:* | Every named database the site exposes (a convenience grant; the site’s allow_imports is still the hard ceiling). |
admin:domains / admin:email / admin:site / admin:secrets | A guest project-self-config surface via boatramp:handlers/admin. Each is a specific surface grant (a bare admin and admin:* are deliberately not accepted — deny-by-default, least-privilege) and is additionally gated by the matching allow_guest_admin_* posture knob (all off under multi-tenant). |
session | A duplex/resumable session route’s per-frame session binding. Accepted in imports but advertised only when the host is built with the session feature (the requires ABI gate enforces host support at activation). |
tenancy | Host-verify a guest-presented token to seal an async-lane producer context (boatramp:handlers/tenancy present-token). Accepted in imports but advertised only when the host is built with the messaging feature. |
wasi:io, wasi:clocks, wasi:random, wasi:logging | Standard host facilities (wasi:logging messages are captured into the site’s logs alongside stdout/stderr). |
The site’s allow_imports is the allowlist; a
handler requesting an import the site does not permit is denied at activation.
invoke_targets
The deny-by-default allowlist of sibling function names this handler may call
through the invoke import. Each entry may use * wildcards (* = any function,
img-* = a family, resize = one literal). It is only consulted when imports
contains invoke (which the site’s allow_imports must also permit); an empty
list means the handler cannot invoke anything even with the import.
handlers: [ (route: "/api", component: "api.wasm", imports: ["invoke"], invoke_targets: ["resize", "img-*"]) ],
upload_containers
The deny-by-default allowlist of blob containers this handler may mint an S3
upload credential for through a blob-upload:write / blob-upload:multipart
import (empty ⇒ deny-all — a handler that declares the import but names no
container can mint nothing). Only consulted when imports contains a
blob-upload:* right the site also permits.
An entry may carry the literal token {tenant} (e.g. "assets-{tenant}").
Before minting, the host substitutes this invocation’s own resolved tenant
(the same ScopeAxis::Tenant fact the SQL scope injector uses — never
guest-supplied) into {tenant}, before the allowlist match and the scope
stamp. This authorizes an unbounded per-tenant container family
(assets-<tid>, one per tenant) that a static exact list could never enumerate,
while keeping a cross-tenant mint structurally impossible: the entry only ever
expands to the guest’s own tenant, so a guest asking for assets-<other-tid> fails
the allowlist. An all / anonymous / target / unscoped invocation has no resolved
tenant, so a {tenant} entry there fails closed (no-resolved-tenant). An
entry without {tenant} keeps plain exact-match (additive, non-breaking).
handlers: [ (route: "/upload", component: "up.wasm", imports: ["blob-upload:write"], upload_containers: ["assets-{tenant}"]) ],
See S3-compatible blob ingress.
limits (HandlerLimits)
| Field | Type | Description |
|---|---|---|
memory_mb | u32? | Max linear memory, MiB. |
timeout_ms | u32? | Wall-clock timeout, ms. |
fuel | u64? | CPU budget in wasmtime fuel units (deterministic instruction-count bound). Omitted = unmetered. |
Each field may only lower the corresponding site cap, never raise it. A request
handler is connection-bearing, so its timeout_ms is additionally capped by the
engine’s sync ceiling (handlers.sync_max_timeout_ms, default 10s) — a route that
declares more is clamped back down. Genuinely long-running work belongs on the
async path (a durable
--async invocation or a workflow), not a request handler held open.
consumers
A component invoked once per message on a topic. See Run consumers, crons, and streams.
| Field | Type | Description |
|---|---|---|
topic | string | Topic to subscribe to. A bus:<topic> prefix subscribes to the shared, project-scoped bus (so producers and consumers in different components meet on one topic); a plain topic is site-private. |
component | string | Path to the component .wasm. |
imports | list<string> | Requested capabilities. |
upload_containers | list<string> | Blob containers this consumer may mint an S3 upload credential for via a blob-upload import — same deny-by-default allowlist and host-forced {tenant} template as a handler’s. |
group | string | Consumer group. Empty (default) = the competing-consumer work-queue (one consumer handles each message); a non-empty name = a durable fan-out subscriber that receives every message on its own cursor, independent of other groups. |
start | latest | earliest | Where a non-empty group starts on first subscription: latest (default — only new events) or earliest (replay the retained backlog). Ignored for the work-queue. |
tenancy | Tenancy? | In-site tenancy decision for this consumer’s sql/orm when a drained message is processed — the async-lane analog of a function’s tenancy. Typically resolves from the signed_context source the producer stamped, e.g. (mode: "scoped", column: "tenant_id", sources: [(kind: "signed_context")], read: "own", write: "own"). Absent ⇒ undeclared (refused under multi-tenant, disabled under single-tenant/dev). |
token_claims | HandlerGraphqlTokenClaims? | JWKS/issuer config verifying an app bearer for a token tenant source (rare on the async lane, but supported when a consumer is invoked with a forwarded bearer). Absent ⇒ the token source can’t verify (fail-closed). |
crons
A scheduled invocation of a declared handler route.
| Field | Type | Default | Description |
|---|---|---|---|
schedule | string | — | Standard 5-field cron (minute hour dom month dow). |
route | string | — | Handler route to invoke; must be served by a declared handler. |
overlap | enum | Skip | Skip a tick if the previous run is still in flight, or Allow concurrent runs. |
streams
A host-level endpoint that fans out messaging topics to connected clients.
| Field | Type | Default | Description |
|---|---|---|---|
route | string | — | Route the endpoint is served at. |
topics | list<string> | — | Topics broadcast to clients (server→client). |
websocket | bool | false | Serve as a WebSocket instead of SSE (adds a client→server direction). |
publish_topic | string? | — | For a WebSocket, the topic client→server messages publish to. Omitted = receive-only. |
sessions
A duplex, resumable session route: a long-lived, client-addressable, bidirectional channel
served by a guest component’s session-handler export, which the host re-enters per inbound frame.
The host opens the session on route (SSE-out + POST-in), binds the verified principal, orders +
resumes outbound frames, and persists a checkpoint. Frames are opaque bytes. Unlike a
stream (host-only pub/sub fan-out), a session runs guest code and carries a
backchannel.
| Field | Type | Default | Description |
|---|---|---|---|
route | pattern | — | Route the session is opened at (the client GETs it for the SSE stream and POSTs inbound frames to it). |
component | string | — | Path to the session component .wasm (exports session-handler). |
imports | list<string> | [] | Requested capabilities — a session handler declares session plus whatever sql/invoke/… it uses per frame. See imports. |
limits | HandlerLimits | — | Optional resource caps, capped by site config at activation. |
env | map<string, string> | {} | Static environment variables (never secrets). |
invoke_targets | list<string> | [] | Function names the session may call via invoke (same contract as a handler’s invoke_targets). |
tenancy | Tenancy? | None (plain) | In-site tenancy decision for this session’s sql/orm. Resolved once at open from the verified source and carried across every re-entry, e.g. (mode: "scoped", column: "tenant_id", sources: [(kind: "token", claim: "tid")], read: "own", write: "own"). |
token_claims | HandlerGraphqlTokenClaims? | None (inherit) | JWKS/issuer config verifying the app bearer for a token-sourced tenant (same as a handler’s). |
Patterns
Route, redirect, rewrite, and header patterns share one matcher syntax:
| Token | Matches | Capture |
|---|---|---|
:name | One path segment | :name in to |
* / /* | The rest of the path | :splat in to |
| literal | Itself | — |
Path normalization (dot-segment collapsing, the trailing-slash policy) runs
before matching, so patterns always see a canonical path and cannot be bypassed
with .. or a double slash. See The request pipeline.
SiteConfig schema
SiteConfig is the site-scoped, mutable config tier: domains, transport
security, visitor access control, handler caps, compression, and the gateway. It
is stored as JSON in the KV (not in a deployment manifest), so it changes
independently of content and does not roll back with a deployment. Most of it is
managed through subcommands rather than edited by hand.
The tiers, contrasted:
Routing (project.cfg) | SiteConfig (KV) | |
|---|---|---|
| Scope | One deployment | The whole site |
| Lifecycle | Immutable, rolls back with content | Mutable, independent |
| Edited via | project.cfg + sync | boatramp domain / access / gateway / API |
Top-level fields
| Field | Type | Default | Managed by |
|---|---|---|---|
version | u32 | 1 | — (pinned at 1) |
domains | DomainConfig | empty | boatramp domain |
security | SecurityConfig | off | API / transport security |
access | AccessConfig | open | boatramp access |
handlers | HandlersSiteConfig? | None (disabled) | handler caps |
compression | CompressionConfig | off | boatramp compression |
gateway | GatewayConfig? | None | boatramp gateway |
domains
The hostnames a site answers to (virtualhost routing). See Serve a custom domain.
| Field | Type | Default | Description |
|---|---|---|---|
primary | string? | — | Canonical hostname (example.com). |
aliases | list<string> | [] | Additional exact hostnames (www.example.com). |
wildcards | list<string> | [] | Wildcard patterns (*.example.com), matched by suffix at any depth. |
canonical_redirect | bool | false | 301 exact-alias hosts to primary (apex↔www). Wildcard hosts serve as-is. |
contexts | map<string, string> | {} | Per-host tenant-context tag: host-or-wildcard → an opaque in-site tenant id, the domain tenant source. Lets one deployment serve many customer storefronts, each domain its own tenant. An exact host with no entry inherits primary’s tag; a subdomain inherits its wildcard’s. Bound as a parameter, never formatted into SQL. |
security
Site-tier transport security. Off by default; opt in once TLS is in front
(directly or via a terminating proxy). The effective scheme is read from
X-Forwarded-Proto behind a trusted proxy. See
Harden the security posture.
| Field | Type | Default | Description |
|---|---|---|---|
https_redirect | bool | false | 301 plain-HTTP requests to HTTPS. |
hsts | Hsts? | — | Send Strict-Transport-Security on HTTPS responses. |
csp | string? | — | Content-Security-Policy header value (opt-in; no safe default for static sites). |
frame_options | string? | — | X-Frame-Options value (DENY, SAMEORIGIN). |
hsts
| Field | Type | Default | Description |
|---|---|---|---|
max_age | u64 | 31536000 | max-age in seconds (one year). |
include_subdomains | bool | true | Apply to subdomains. |
preload | bool | false | Request browser-preload-list inclusion (hard to undo — explicit opt-in). |
access
Visitor access control — WAF, IP rules, rate limiting, basic auth, trusted-proxy
handling. This is the full mechanism for restricting who may view a site; it is
separate from control-plane RBAC. Managed with boatramp access and
documented in Restrict visitor access.
handlers
Site-scoped handler policy: the capability allowlist and resource caps a
deployment’s requested handler config is intersected
against at activation (deny by default). None disables handlers for the site
entirely.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Whether handlers run for this site at all. |
allow_imports | list<string> | [] | Interfaces handlers on this site may import (subset of the import vocabulary). |
max_memory_mb | u32? | — | Cap on per-handler memory (MiB). |
max_timeout_ms | u32? | — | Cap on per-handler wall-clock timeout (ms). |
max_concurrency | u32? | — | Cap on concurrent invocations for the site. |
max_fuel | u64? | — | Cap on per-handler CPU fuel; a handler’s own fuel may only lower it. |
secrets | map<string, string> | {} | Env-var name → secret reference (a host env-var name, resolved server-side — never a literal secret). |
background_aliases | list<string> | [] | Named aliases (besides current) whose deployments also run consumers and crons. See Run background work. |
max_stream_connections | u32? | — | Cap on concurrent SSE/WebSocket connections for the site. |
max_log_rate | u32? | — | Cap on captured guest log lines per second (over-cap lines are dropped, counted). |
disable_log_capture | bool | false | Opt out of capturing guest stdout/stderr + wasi:logging. Capture is on by default (logs endpoint + SSE tail + serve.log mirror); set true to discard it, e.g. when guest output may carry secrets/PII. |
cache | HandlerCacheConfig? | None (off) | Edge response cache. |
graphql | HandlerGraphqlConfig? | None (off) | GraphQL edge features. |
cookie_auth | CookieAuthConfig? | None (off) | Browser cookie session auth. |
tenancy | Tenancy? | None (undeclared) | Site-level in-site tenancy decision for sql/orm access — the ceiling for this site’s handlers. |
allow_ceiling_exceptions | bool | false | Whether a route may deliberately exceed this site’s tenancy ceiling via exceed_site_ceiling (key 1 of the three-key model). While false, every route’s exception token is inert (a widening still fails closed) — so a site at the default is provably exception-free. Enabling it authorizes nothing by itself: a route must also carry exceed_site_ceiling: true, and an all grant still needs the operator allow_cross_tenant_db posture. |
A handler that requests an import not in allow_imports, or exceeds a cap, is
rejected at activation — not at request time. See
Handler host bindings.
handlers.cache
Host-level response cache: a cacheable GET/HEAD response is served for a
later identical request without re-instantiating the handler. Opt-in per
response, driven by the handler’s own Cache-Control; never caches a private
response (no-store/private/no-cache, a Set-Cookie, Vary: *, or an
Authorization request without public/s-maxage). Entries are keyed by the
request’s project-qualified scope, honor Vary, and expire by TTL. Backed by the
site’s KV store. See Cache handler responses.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Master switch; inert even if present when false. |
max_entry_bytes | u64? | 262144 (256 KiB) | Largest cacheable entry (status+headers+body); a bigger response streams through uncached. |
max_ttl_secs | u64? | 3600 | Upper bound on a stored entry’s TTL, clamping an over-long max-age. |
handlers.graphql
GraphQL edge features. Off unless present + enabled. See
Serve a GraphQL API.
Renamed in v0.6.0 (breaking). The old
safelist: booltoggle is nowenforce_safelist: bool— the enforcement switch, made distinct from the new declarative source of operations (safelisted_ops/safelisted_ops_path). Rename the field in any site config that set the oldsafelist:.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Master switch for the GraphQL edge. |
max_depth | u32? | server default | Deepest allowed selection nesting (fragments expanded). |
max_complexity | u32? | server default | Largest allowed total field count (schema-free cost proxy). |
introspection | bool? | posture default | Allow schema-introspection queries (off under the multi-tenant posture). |
persisted_queries | bool | false | Resolve a query hash to the stored query (bandwidth + parse saving). |
enforce_safelist | bool | false | Enforcement switch: only pre-registered operations run (a deny-by-default query allowlist); the edge never registers a new query. Implies and is stronger than persisted_queries. Renamed from safelist in v0.6.0 (breaking). |
safelisted_ops | [String] | [] | Declarative source of persisted operations, inline: operation texts registered in the project’s safelist at apply time via the control-plane safelist endpoint (server-side guard_query validation — never a direct KV write). Register-only (union) — applying only ever ADDS; removal stays the explicit boatramp graphql safelist rm. Mutually exclusive with safelisted_ops_path. Authoring-only (not stored in the site config). |
safelisted_ops_path | path? | None | Declarative source of persisted operations, from a file (resolved client-side, relative to the manifest dir; only operation text crosses the wire, never the path). Same register-only union semantics as safelisted_ops. Mutually exclusive with it. |
federated | bool | false | This site is a supergraph gateway: plan a query against the project’s registered subgraphs and dispatch fetches to them. |
graphiql | bool | false | Serve the in-browser GraphiQL explorer to a browser GET. |
data | HandlerGraphqlDataConfig? | None | Declarative data connector: generate the API from a managed database (queries compiled to SQL). Deny-by-default exposure; a claims_from_token block can bind a claim from a verified application bearer for multi-tenant row isolation. |
handlers.cookie_auth
Browser cookie session auth. Off unless present. A request carrying the named
cookie but no Authorization header is authenticated from the cookie value —
boatramp injects it as the app bearer everywhere the header bearer flows (the
Authorization header always wins). boatramp only reads the cookie; the app
sets, refreshes, and verifies it. Set the cookie HttpOnly; Secure; SameSite=Lax
with a __Host- prefix. See
Authenticate a browser with a session cookie.
| Field | Type | Default | Description |
|---|---|---|---|
cookie_name | string | — | The cookie whose value becomes the bearer when no Authorization header is present. |
allowed_origins | list<string> | [] | Additional cross-origin CSRF allowlist. Same-origin (request Origin/Referer authority == own Host) always passes, so [] ⇒ same-origin only — no config for the usual SPA. List the extra origins a browser app on a different origin than this API may use; a cross-origin request that’s neither same-origin nor listed is rejected 403. Each entry is a scheme://host[:port] origin. |
handlers.tenancy
The site’s in-site tenancy decision — how the host scopes sql/orm row access across
sub-tenants sharing one database. Absent (None) means undeclared: refused at activation for a
sql/orm-importing site under the multi-tenant posture (which requires an explicit decision),
treated as disabled under single-tenant/dev. Three shapes (a tagged mode):
{ "mode": "disabled" }
Deliberately no in-site tenancy — plain queries (the project = database boundary is the whole isolation). The explicit “single-tenant / no tenancy” declaration.
{ "mode": "scoped",
"column": "tenant_id",
"sources": [ { "kind": "token", "claim": "tid" } ],
"read": "own",
"write": "own" }
In-site sub-tenancy on column, resolving “own” from the first applicable sources entry, at
per-axis access grants. In project.cfg / apply.cfg RON the canonical spelling is
(mode: "scoped", column: "tenant_id", sources: [(kind: "token", claim: "tid")], read: "own", write: "own").
| Field | Type | Default | Description |
|---|---|---|---|
column | string | — | The tenant column the host scopes on (validated as an identifier). |
sources | list<TenantSource> | [{"kind":"none"}] | Host-verified sources the “own” tenant resolves from, in priority order — the host picks the first whose current-trigger input is present (a token on an authenticated request, domain on a storefront, signed_context on an async job), so one component serves multiple trigger kinds. The pre-Stage-2 singular source: {…} field is still accepted (a one-element list) for back-compat. |
read | AccessMode | own | Which tenant-set reads may reach. |
write | AccessMode | own | Which tenant-set writes may reach. |
Each TenantSource is {"kind":"token","claim":"tid"} (a verified JWT claim, claim default
tid), {"kind":"domain"} (the routed domain’s contexts tag), {"kind":"signed_context"}
(a host-verifiable envelope on an async job/message — the async-lane “own”), or {"kind":"none"}
(anonymous — an “own” grant then fails closed).
AccessMode is one of none (deny), null (the tenant_id IS NULL shared baseline only), own
(the resolved tenant), own_or_null (both), or all (cross-tenant — default-deny, gated by the
allow_cross_tenant_db posture ceiling; capped to own when off). own_or_null
on the write axis degrades to own (a write never touches the shared baseline). A top-level
function’s own tenancy block narrows within this site ceiling. Full model:
Isolate tenants in one database.
{ "mode": "target",
"via": [ "domain" ],
"public": "storefront",
"write": [ "status" ],
"null_base": false }
Target: this route reads (and, with a non-empty write allowlist, writes) a second tenant
B’s PUBLIC subset — never the caller’s own tenant. The host resolves B from the first applicable
via source and binds the target scope before the guest runs, confining every access to
tenant = B AND <public subset>. Gated by the operator’s target_eligible_fields
allowlist.
| Field | Type | Default | Description |
|---|---|---|---|
via | list<TargetSource> | — | Prioritized target-source list (first-resolves-wins): "domain" (the terminating request domain, write-capable), "handle" (a public slug from a third-party origin — read-only, admissible only on a world_public subset), or "capability" (a host-verified capability token carrying tid/sub — the token is the authorization, can back a target write). |
public | string | — | Names the host-held public subset (a table in the project’s public_subsets) accesses confine to. |
write | list<string> | [] | Deny-by-default SET-allowlist of columns a target write (INSERT/UPDATE via the typed orm only) may set. Empty ⇒ read-only. The tenant + visibility columns must not appear here (a target write can’t change ownership or flip visibility); a DELETE and any raw-SQL write are refused. |
null_base | bool | false | target_or_null: when true, a target READ confines to (<tenant col> = B OR <tenant col> IS NULL) AND <public subset> — B’s public rows plus the shared NULL-tenant base/reference rows. Read-only (a target write still stamps B); the NULL disjunct is added only on plain tenant-column tables. |
TenancySchema
The project-level tenant-isolation schema — the host-held facts the scope injector keys off,
declared per project (not per component) with boatramp tenancy apply,
not in SiteConfig. A target tenancy decision (above) needs the operator to have opened the axis
in this schema; a project that declares none uses the legacy single-column scoping. Key fields:
| Field | Type | Description |
|---|---|---|
default_tenant_key | string | The tenant column for a tenant-scoped table (default tenant_id). |
session_key | string? | The anonymous-session column for tenant_or_session tables, present iff the project uses the session axis. |
tables | map<string, TableScope> | Per-table scope facts, authoritative + exhaustive when present (a table with no entry is refused, deny-by-default). A TableScope is tenant, tenant_keyed { key }, unscoped, or tenant_or_session. |
target_eligible_fields | set<string> | The operator’s allowlist ceiling: which root Query/Mutation fields (and plain-wasm route ids) may carry a target scope at all. Empty ⇒ no field may be target (deny-by-default). |
public_subsets | map<string, PublicSubset> | Per-table PUBLIC subset definitions a target read/write confines to: a visibility predicate (a closed conjunction of column <op> literal / null-test terms) plus the deny-by-default world_public (admits the anonymous handle source) and listable (handle-discoverable) flags. A target field over a table with no entry is refused. |
handles | map<string, string> | Operator-curated PUBLIC handle/slug → target tenant context tag B. A handle target source resolves B only for a slug listed here (deny-by-default) and only when the route’s public subset is world_public. |
compression
On-the-fly response compression. Opt-in, and complementary to serving a
precompressed variant. A response is compressed only when it has no precompressed
variant or existing Content-Encoding, its type is compressible, and (when the
length is known) it is at least min_size. Credentialed responses are skipped
for BREACH safety. See Compress responses.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Master toggle. |
min_size | u64 | 1024 | Don’t compress a response with a Content-Length below this (bytes). Streaming responses with no declared length are always eligible. |
gateway
Reverse-proxy gateway for publishing private services. None means no gateway
routes. Declaring an upstream here is what authorizes reaching a private address
— the SSRF guard stays public-only otherwise. Fields
cover upstream pools, load balancing, and health checking; see
Expose a private service through the gateway.
Environment variables
boatramp reads its configuration from three places, in precedence order: command-line flag > environment variable > config file. Every variable below overrides the corresponding config field and is itself overridden by an explicit flag. Secrets (tokens, signing keys) belong in the environment rather than in a config file on disk.
Client commands
Read by sync, build, bundle, and the other project commands. See
project.cfg.
| Variable | Overrides | Description |
|---|---|---|
BOATRAMP_SERVER | publish.server | Server base URL. |
BOATRAMP_SITE | publish.site | Site to publish to. |
BOATRAMP_PROJECT | publish.project | Target project for site-scoped commands; falls back to [publish].project, then the default project. |
BOATRAMP_TOKEN | publish.token | Control-plane token. Prefer the env var so it is never on disk. |
BOATRAMP_TOKEN_HOLDER_KEY | — | Holder private key ("<alg>:<hex>") for a PoP-bound token: every request is signed with a fresh proof. Inert unless set alongside BOATRAMP_TOKEN + BOATRAMP_POP_ORIGIN. See PoP-bind a token. |
BOATRAMP_POP_ORIGIN | — | The server’s canonical origin the PoP proof binds (aud); must equal the server’s serve.pop_origin. |
BOATRAMP_MCP_CONFIG | — | Path to the MCP instance registry (default ~/.config/boatramp/mcp.toml). |
Server (serve)
Read by boatramp serve. Each maps to a serve.* field in
boatramp.cfg; the flag of the same name wins over both.
| Variable | Description |
|---|---|
BOATRAMP_ADDR | Address to bind (e.g. 0.0.0.0:8080). |
BOATRAMP_DATA_DIR | Data directory (blobs + embedded KV). |
BOATRAMP_DEFAULT_SITE | Site to serve for an unmatched Host instead of 404. |
BOATRAMP_POP_ORIGIN | Canonical origin a per-request proof-of-possession must bind (serve.pop_origin). Required for holder-bound (cnf/PoP) tokens; compared against the proof, never a request header. |
BOATRAMP_HTTP_REDIRECT_ADDR | In a TLS mode, a second plain-HTTP listener that 308-redirects to HTTPS (e.g. 0.0.0.0:80). |
BOATRAMP_PROTECT_PREVIEWS | Require a valid token to view deployment previews. |
BOATRAMP_LOG_FORMAT | json for structured logs (anything else = human-readable). |
Upload limits
| Variable | Description |
|---|---|
BOATRAMP_MAX_UPLOAD_BYTES | Reject blob uploads larger than this (default: unlimited). |
BOATRAMP_UPLOAD_IDLE_TIMEOUT | Abort an upload stalled this many seconds (slowloris guard). |
BOATRAMP_MAX_CONCURRENT_UPLOADS | Cap simultaneous uploads; further uploads get 503 until a slot frees. |
Authentication & tokens
See Bootstrap authentication and Authentication & authorization.
| Variable | Description |
|---|---|
BOATRAMP_AUTH_ROOT_PUBLIC_KEY | The trust anchor. Every node needs it to verify tokens. |
BOATRAMP_AUTH_ROOT_PRIVATE_KEY | The signing key. Needed only where tokens are minted; keep it off verify-only nodes. |
BOATRAMP_BOOTSTRAP_SECRET | Single-use secret that mints the first admin token, then is retired. |
BOATRAMP_HOLDER_KEY | Holder private key used to sign an offline delegation with token attenuate. |
An external signer (KMS/HSM/Vault) replaces BOATRAMP_AUTH_ROOT_PRIVATE_KEY
with its own credentials — see
Hold the signing key in a KMS/HSM/Vault.
OIDC federation
For exchanging an identity-provider JWT for a boatramp token. See Federate CI auth with OIDC.
| Variable | Description |
|---|---|
BOATRAMP_OIDC_ISSUER | Trusted issuer URL (its JWKS is fetched for verification). |
BOATRAMP_OIDC_AUDIENCE | Required audience claim. |
BOATRAMP_OIDC_SCOPE_CLAIM | Claim carrying the granted roles. |
Cluster & shared-store frontends
| Variable | Description |
|---|---|
BOATRAMP_CLUSTER_RATE_LIMIT | Rate-limit cluster-wide via the shared KV instead of per-node buckets. |
BOATRAMP_SHARED_CACHE_COHERENCE | Keep local config caches coherent across frontends sharing one KV. See Cache coherence. |
BOATRAMP_BLOBS | Blob backend (fs, s3, gcs, azure); env form of --blobs. |
BOATRAMP_KV | Metadata KV backend (slatedb, memory, cloudflare); env form of --kv. |
BOATRAMP_KV_S3 | Run the SlateDB control-plane KV on the S3/R2 object store (reusing the --blobs s3 config) instead of local disk — durable metadata for a volumeless container. Env form of --kv-s3. |
BOATRAMP_KV_S3_PREFIX | Key prefix for the --kv-s3 store within the bucket (default _kv). |
BOATRAMP_S3_BUCKET | S3/R2 bucket for s3 blobs and (with --kv-s3) the SlateDB KV. |
BOATRAMP_S3_ENDPOINT | S3-compatible endpoint URL (R2: https://<account>.r2.cloudflarestorage.com). |
BOATRAMP_S3_REGION | Bucket region (R2 uses auto). |
BOATRAMP_S3_PATH_STYLE | Use path-style addressing (for non-AWS endpoints; R2 accepts it). |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY | Credentials for the s3/R2 backend (standard AWS resolution). |
Compute backend
Map to the [compute] section in boatramp.cfg. Set any of
these and the section is enabled even without a config file (a set variable wins
over its file value; an unset one defers to the file/default). See
Run compute workloads.
| Variable | Overrides | Description |
|---|---|---|
BOATRAMP_COMPUTE_BRIDGE | compute.bridge | Bridge the container veths / VM taps attach to (default br-boatramp). |
BOATRAMP_COMPUTE_SUBNET | compute.subnet | Guest IP subnet (default 10.0.0.0/24). |
BOATRAMP_COMPUTE_VCPUS | compute.vcpus | vCPUs advertised as schedulable (0 = detect from the host). |
BOATRAMP_COMPUTE_MEM_MIB | compute.mem_mib | Memory (MiB) advertised as schedulable (0 = a 1 GiB default). |
BOATRAMP_COMPUTE_REGION | compute.region | This node’s region tag for nearest-replica routing. |
BOATRAMP_COMPUTE_SQL_SHIM_URL | compute.sql_shim_url | Guest-reachable base URL of the compute SQL shim (enables a workload’s --bind sql). |
BOATRAMP_COMPUTE_MANAGED_DB_PRIVILEGE | compute.managed_db_privilege | How a managed DB image runs on a shared-kernel backend: rootless (default) or caps. |
BOATRAMP_COMPUTE_DOCKER_ENDPOINT | compute.docker_endpoint | Remote-Docker endpoint mode: published (default) or bridge. |
BOATRAMP_COMPUTE_DOCKER_VOLUME_MODE | compute.docker_volume_mode | Remote-Docker volume mode: named (default) or bind. |
BOATRAMP_COMPUTE_KERNEL_SIGNING_PUBKEYS | compute.kernel_signing_pubkeys | Comma-separated <alg>:<hex> kernel-signing trust anchors (replaces, not appends to, the defaults). |
BOATRAMP_COMPUTE_KERNEL_ALLOWED_HASHES | compute.kernel_allowed_hashes | Comma-separated sha256-hex allow-list of kernel content hashes (replaces the defaults). |
BOATRAMP_COMPUTE_INTERNAL_DNS | compute.internal_dns | Run the per-project internal DNS resolver on the bridge gateway so a guest resolves a sibling workload by name (default true; Linux + container backend). See internal name resolution. |
BOATRAMP_COMPUTE_DNS_UPSTREAM | compute.dns_upstream | Upstream resolver (host:port) the internal DNS forwards external names to (default 1.1.1.1:53). |
BOATRAMP_COMPUTE_DNS_DOMAIN | compute.dns_domain | Internal DNS suffix names live under, <workload>.<project>.<domain> (default boatramp.internal). |
Security-critical: BOATRAMP_COMPUTE_KERNEL_SIGNING_PUBKEYS and
BOATRAMP_COMPUTE_KERNEL_ALLOWED_HASHES are the kernel trust anchors for the
posture-scaled kernel bar — a value here decides which kernels a multi-tenant
node will boot. In a 12-factor deployment the environment is the operator’s
trusted config source (a fly.toml [env] is committed the same as a file), so
they are settable here; but the environment is more visible than a file (it
leaks through /proc/<pid>/environ and is inherited by subprocesses), so prefer
a config file for them when one is available.
Security posture
Map to the [security] section in boatramp.cfg. Setting any
of these materialises the posture even without a config file: an unset section
resolves to the strict multi-tenant default and each variable layers over it
exactly as a file overrides block would (a set variable wins over the file).
See boatramp.cfg and
boatramp security explain. Byte caps take 0 = unlimited; booleans accept
true/false, 1/0, yes/no, on/off.
| Variable | Overrides | Description |
|---|---|---|
BOATRAMP_SECURITY_PROFILE | security.profile | Base profile: multi-tenant (default), single-tenant, dev, or a custom profiles name. |
BOATRAMP_SECURITY_ALLOW_UNAUTHENTICATED_PUBLIC_BIND | overrides.allow_unauthenticated_public_bind | Permit a non-loopback bind with control-plane auth disabled. |
BOATRAMP_SECURITY_MAX_UPLOAD_BYTES | overrides.max_upload_bytes | Default blob-upload cap in bytes (0 = unlimited). |
BOATRAMP_SECURITY_ALLOW_SITE_UNIX_UPSTREAMS | overrides.allow_site_unix_upstreams | Permit site-declared unix: gateway upstreams. |
BOATRAMP_SECURITY_ALLOW_SITE_PRIVATE_UPSTREAMS | overrides.allow_site_private_upstreams | Permit site-declared gateway upstreams to private/loopback IPs. |
BOATRAMP_SECURITY_ALLOW_GUEST_PRIVATE_EGRESS | overrides.allow_guest_private_egress | Permit a guest’s outbound wasi:http to reach private/loopback IPs. |
BOATRAMP_SECURITY_ALLOW_GUEST_SELF_EGRESS | overrides.allow_guest_self_egress | Permit a guest’s outbound wasi:http to reach this instance’s own serve socket. |
BOATRAMP_SECURITY_ALLOW_GUEST_EGRESS_EXTRA_CA | overrides.allow_guest_egress_extra_ca | Permit the guest egress TLS client to trust an operator-supplied extra CA (BOATRAMP_GUEST_EGRESS_EXTRA_CA_FILE) on top of the webpki roots. Widens trust, never bypasses verification. Off under multi-tenant. |
BOATRAMP_SECURITY_MAX_HANDLER_BLOB_BYTES | overrides.max_handler_blob_bytes | Cap on handler blobstore host reads/ranges/copies (0 = unlimited). |
BOATRAMP_SECURITY_MAX_COMPONENT_BYTES | overrides.max_component_bytes | Cap on a Wasm component blob (0 = unlimited). |
BOATRAMP_SECURITY_OIDC_REQUIRE_AUDIENCE | overrides.oidc_require_audience | Require an OIDC audience when OIDC is enabled. |
BOATRAMP_SECURITY_DOMAIN_VERIFY_ALLOW_PRIVATE | overrides.domain_verify_allow_private | Permit HTTP domain-verification probes to private hosts. |
BOATRAMP_SECURITY_DOMAIN_VERIFY_SELF_SERVE | overrides.domain_verify_self_serve | Serve pending ownership challenges from the edge (the domain-attach fix). |
BOATRAMP_SECURITY_ALLOW_SHARED_KERNEL_COMPUTE | overrides.allow_shared_kernel_compute | Permit untrusted workloads on shared-kernel compute backends. |
BOATRAMP_SECURITY_ALLOW_COMPUTE_EXEC | overrides.allow_compute_exec | Permit boatramp compute exec (run a command inside a running workload). Off in every profile but dev — it is arbitrary code execution in the workload; opt in for migrations/backups/debug. |
BOATRAMP_SECURITY_RATELIMIT_FAIL_OPEN | overrides.ratelimit_fail_open | Fail open instead of closed when the rate-limit KV is unreadable. |
BOATRAMP_SECURITY_ALLOW_IMPLICIT_ROUTING | overrides.allow_implicit_routing | Resolve an unmatched Host to a site without an explicit domain registration. |
BOATRAMP_SECURITY_REQUIRE_POP | overrides.require_pop | Require every token to be cnf-bound and PoP-proven fleet-wide. |
BOATRAMP_SECURITY_REQUIRE_DOMAIN_VERIFICATION | overrides.require_domain_verification | Refuse to serve a non-local Host that isn’t a verified, attached virtualhost. |
BOATRAMP_SECURITY_ALLOW_ENV_SECRET_REFS | overrides.allow_env_secret_refs | Permit a handler’s / function’s secrets map to name a bare / env:-scheme reference into the serve process’s own environment. Off under multi-tenant. |
BOATRAMP_SECURITY_REQUIRE_TENANCY_DECLARATION | overrides.require_tenancy_declaration | Require every sql/orm-opening component to make an explicit in-site tenancy decision (disabled or scoped). On under multi-tenant. |
BOATRAMP_SECURITY_ALLOW_CROSS_TENANT_DB | overrides.allow_cross_tenant_db | Permit a component to declare a cross-tenant (all) read/write access mode. Off under multi-tenant (capped to own). |
BOATRAMP_SECURITY_ALLOW_GUEST_MINT_CAPABILITY | overrides.allow_guest_mint_capability | Permit a guest’s capability capability to mint fleet-signed target-capability tokens. Off under multi-tenant. |
BOATRAMP_SECURITY_MAX_GUEST_CAPABILITY_TTL_SECS | overrides.max_guest_capability_ttl_secs | Operator ceiling (seconds) on a guest-minted capability’s TTL; a larger request is clamped. 0 disables minting. |
BOATRAMP_SECURITY_ALLOW_GUEST_EMAIL | overrides.allow_guest_email | Permit a guest handler’s/function’s email capability to send. Off under multi-tenant (an SMTP profile + secrets envelope still gate actual delivery). |
BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_DOMAINS | overrides.allow_guest_admin_domains | Permit a guest’s admin capability to manage the project’s domains. Off under multi-tenant. |
BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_EMAIL | overrides.allow_guest_admin_email | Permit a guest’s admin capability to manage the project’s SMTP email profiles. Off under multi-tenant. |
BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_SITE | overrides.allow_guest_admin_site | Permit a guest’s admin capability to write the project’s site config + aliases. Off under multi-tenant. |
BOATRAMP_SECURITY_ALLOW_GUEST_ADMIN_SECRETS | overrides.allow_guest_admin_secrets | Permit a guest’s admin capability to write the project’s sealed secrets. Off under multi-tenant. |
The four tenancy/capability variables above set the fleet posture; a per-project override of
the same knobs is config-file only (see
security.projects). Guest-admin surface knobs
(allow_guest_admin_*) and allow_guest_email are file-only — not env-settable.
Handler backends
The [handlers.bindings.sql] knobs (the single managed-SQL backend) map to
these; set any and the section is created even without a config file (env wins
over the file value; secrets stay indirected via the *_TOKEN_ENV names, never
the token itself). See Handler bindings.
| Variable | Overrides | Description |
|---|---|---|
BOATRAMP_HANDLERS_SQL_DIR | bindings.sql.dir | Single-node: root dir for the per-site embedded databases (default <data-dir>/handlers-sql). |
BOATRAMP_HANDLERS_SQL_URL | bindings.sql.url | Cluster: base sqld data URL — switches from single-node to a shared sqld cluster. |
BOATRAMP_HANDLERS_SQL_ADMIN_URL | bindings.sql.admin_url | Cluster: sqld admin API base URL (required when url is set). |
BOATRAMP_HANDLERS_SQL_REPLICA_URL | bindings.sql.replica_url | Cluster: optional read-replica data URL for read-only transactions. |
BOATRAMP_HANDLERS_SQL_TOKEN_ENV | bindings.sql.token_env | Name of the env var holding the sqld data auth token. |
BOATRAMP_HANDLERS_SQL_ADMIN_TOKEN_ENV | bindings.sql.admin_token_env | Name of the env var holding the sqld admin API key. |
BOATRAMP_HANDLERS_SQL_PREVIEW_MODE | bindings.sql.preview_mode | Preview-database policy: empty (default), branch, or shared. |
BOATRAMP_HANDLERS_SQL_PREVIEW_INIT | bindings.sql.preview_init | Path to an idempotent SQL script run when an empty preview db is first opened. |
BOATRAMP_HANDLERS_SQL_DEPROVISION_GRACE_SECS | bindings.sql.deprovision_grace_secs | Soft-delete grace window (seconds) for a per-tenant managed DB. Default 604800 (7 days). On a project/site delete, a Shared + Postgres tenant is soft-deleted (its database renamed aside, role disabled) and stays recoverable for this long before a reaper hard-drops it; 0 disables the soft path (immediate, irreversible hard drop). MySQL and all Single tenants always hard-drop immediately. |
BOATRAMP_SQL_TOKEN | — | Auth token for a remote libsql database referenced by the SQL binding. |
(your url_env) | — | Connection URL (a secret) for an external bring-your-own SQL database — the var name is whatever you set as url_env / read_url_env under [handlers.bindings.sql.databases]. See Bring your own database. |
BOATRAMP_FC_* | — | Embedded-VMM / Firecracker compute-backend settings (kernel, rootfs, bridge, subnet, …). See Run compute workloads. |
BOATRAMP_VMM_SERIAL | — | Attach the microVM serial console (debugging). |
External SQL databases ([handlers.bindings.sql.databases])
The bring-your-own / managed-compute database map is env-settable too, so a
managed co-located Postgres needs no config file. Each database <NAME> is
declared by setting one or more BOATRAMP_HANDLERS_SQL_DB_<NAME>_<FIELD>
variables; the member names are discovered from the environment (there is no
file to enumerate them). An env-declared database is merged over — per field, by
key — whatever the file declared under that name.
The default database (the empty-string map key, opened as sql.open("")) is
addressed by the reserved name token DEFAULT:
BOATRAMP_HANDLERS_SQL_DB_DEFAULT_KIND populates the "" key.
<FIELD> is one of KIND, URL_ENV, READ_URL_ENV, COMPUTE, DATABASE,
USER, PASSWORD_ENV, POOL_MAX, READ_ONLY, ALLOW_PREVIEW,
CONNECT_TIMEOUT_SECS, IMAGE, VOLUME_SIZE_MIB, STARTUP_GRACE_SECS (each
mirrors a field of the RON databases entry; secrets stay indirected via the
*_ENV names). STARTUP_GRACE_SECS sets how long a freshly launched managed-DB
replica may take to become healthy before the reconcile treats it as a broken
launch; omit it for the per-engine default (Postgres 60 s, MySQL 120 s). See
Startup grace. Example — a
managed Postgres as the default database, with boatramp managing the credential (no
PASSWORD_ENV):
BOATRAMP_HANDLERS_SQL_DB_DEFAULT_KIND=postgres
BOATRAMP_HANDLERS_SQL_DB_DEFAULT_COMPUTE=pg
BOATRAMP_HANDLERS_SQL_DB_DEFAULT_DATABASE=appdb
BOATRAMP_HANDLERS_SQL_DB_DEFAULT_USER=app
For a managed co-located database (COMPUTE set, no PASSWORD_ENV), boatramp
auto-registers the backing compute workload — so the four lines above are enough
to boot a Postgres; no separate compute set / apply step. IMAGE overrides the
stock image (default pgvector/pgvector:pg16 for postgres, mysql:8.0 for mysql)
and VOLUME_SIZE_MIB the persistent data-volume size (default 10240 = 10 GiB). An
operator-declared workload of the same name always wins over the auto-registered one.
Handler secrets are injected by reference: the site config names a host
env-var, and the server resolves it at instantiation so the literal never lands
in a manifest. See Handler host bindings.
Secrets envelope
Map to the [secrets] section in boatramp.cfg — envelope
encryption for private keys at rest. Set any and the section is created even
without a config file. kek_file holds a path (never key material); the Vault
token stays indirected via token_env (a variable name, not the token).
| Variable | Overrides | Description |
|---|---|---|
BOATRAMP_SECRETS_ENVELOPE | secrets.envelope | Backend: local (machine-local AES-256-GCM KEK) or vault (Vault Transit). |
BOATRAMP_SECRETS_KEK_FILE | secrets.kek_file | Path to the local-KEK key file (auto-generated 0600 if absent). Default <data-dir>/secrets/kek. |
BOATRAMP_SECRETS_VAULT_ADDR | secrets.vault.addr | Vault address, e.g. https://vault:8200. |
BOATRAMP_SECRETS_VAULT_KEY | secrets.vault.key | Vault Transit key name to wrap under. |
BOATRAMP_SECRETS_VAULT_TOKEN_ENV | secrets.vault.token_env | Name of the env var holding the Vault token (default VAULT_TOKEN). |
Cluster section ([cluster])
Map to the [cluster] section in boatramp.cfg — the
self-hosted cluster mode’s own config, distinct from the founding/joining
action flags in the Cluster & shared-store frontends
table above (BOATRAMP_CLUSTER_INIT / BOATRAMP_CLUSTER_JOIN /
BOATRAMP_CLUSTER_ADVERTISE_ADDR). A BOATRAMP_CLUSTER_LISTEN materialises an
absent section (a node must know where to bind its mesh); the other fields then
layer on. List-valued vars are comma-separated.
| Variable | Overrides | Description |
|---|---|---|
BOATRAMP_CLUSTER_LISTEN | cluster.listen | Address to bind this node’s Raft peer mesh on (e.g. 10.0.0.2:7000). Required to materialise an absent section. |
BOATRAMP_CLUSTER_ROOT_PUBKEYS | cluster.root_pubkeys | Comma-separated es256:/ed25519: root anchor set defining the cluster identity. |
BOATRAMP_CLUSTER_SEEDS | cluster.seeds | Comma-separated control-plane addresses of existing members to join through. |
BOATRAMP_CLUSTER_JOIN_TOKEN | cluster.join_token | Single-use join token (kept out of plain sight via an env:VAR / path:/file prefix). |
BOATRAMP_CLUSTER_STORE_DIR | cluster.store_dir | Directory for this node’s durable Raft log/state (default <data-dir>/raft). |
BOATRAMP_CLUSTER_MESH_KEY_FILE | cluster.mesh.key_file | Path to this node’s Ed25519 mesh identity key (auto-generated 0600). |
BOATRAMP_CLUSTER_MESH_KEY_ROTATION | cluster.mesh.key_rotation | Automatic mesh key-rotation cadence (e.g. 30d). |
BOATRAMP_CLUSTER_MESH_JOIN_TOKEN_TTL | cluster.mesh.join_token_ttl | TTL for a single-use join token (e.g. 1h). |
BOATRAMP_CLUSTER_MESH_GATE_CLIENT_WRITES | cluster.mesh.gate_client_writes | Gate mesh client-writes behind a control-plane cluster-write capability. |
TLS / ACME (incl. wildcard DNS-01)
The listener’s TLS mode and the ACME issuance parameters — previously serve flags
only — are env-settable too, so wildcard DNS-01 can be configured with no config file
(e.g. a fly [env]). The DNS provider credentials are already env-only (below).
| Variable | Flag | Description |
|---|---|---|
BOATRAMP_TLS | --tls | Listener TLS mode: off (default), custom, acme, acme-dns, rpk. Use acme-dns for wildcard certs. |
BOATRAMP_ACME_DOMAINS | --acme-domain | Comma-separated domains to certify. An explicit wildcard (*.example.com) is issued via DNS-01. |
BOATRAMP_ACME_DNS_PROVIDER | --acme-dns-provider | DNS-01 provider: manual, cloudflare, route53, oci, digitalocean, hetzner, ns1, dnsimple, gcp, azure, akamai. |
BOATRAMP_ACME_CONTACT | --acme-contact | Contact email for the ACME account. |
BOATRAMP_ACME_DIRECTORY | --acme-directory | ACME directory URL (default Let’s Encrypt production). |
BOATRAMP_ACME_CACHE | --acme-cache | Certificate cache directory (default ./data/acme). |
BOATRAMP_ACME_CA_CERT | --acme-ca-cert | Extra root CA (PEM) to trust for the ACME server (e.g. Pebble’s). |
BOATRAMP_ACME_WILDCARD_PREVIEW | --acme-wildcard-preview | Also issue a *.deploy.<domain> wildcard for preview hosts (true/false). |
An exact host (an apex/www/console site or cert) always wins over a wildcard,
in both host→site routing and SNI cert selection — so *.example.com never shadows a
declared exact host.
DNS provider credentials
Auto-DNS and --tls acme-dns read provider credentials (CLOUDFLARE_API_TOKEN,
AWS_KEY, HETZNER_DNS_TOKEN, …) from the environment. Each provider’s exact
variables are listed in
DNS providers & credentials.
Test-only variables
Variables prefixed BOATRAMP_TEST_ gate #[ignore] live integration tests
(cloud KMS, SoftHSM, libsql, Docker, S3). They have no effect on a running
server and are not part of the operational surface.
Control-plane HTTP API
The control-plane API is the transport the CLI speaks to a server. Most operators
never call it directly — the boatramp subcommands wrap it — but it is a stable,
documented surface for building your own tooling. This page lists the endpoints;
the CLI reference maps each command onto them.
Conventions
- Base path. Every control-plane endpoint is under
/api. Public serving (host-routed content,/_sites/*,/healthz) is a separate, unauthenticated surface. - Authentication. A bearer token in
Authorization: Bearer <token>. Every/api/*request is authenticated and authorized, except the handful gated by their own single-use credential (bootstrap, join, OIDC exchange). The exact right each endpoint requires is in the request-to-right mapping. - Bodies. Requests and responses are JSON, except blob upload (raw bytes) and
/api/metrics(Prometheus text). - Errors. A non-2xx status carries a JSON
{ "error": "..." }.401is a missing or invalid token;403is a valid token without the required right.
Projects
A project owns sites, functions, and compute, and is the
tenant boundary. Since 0.2.0 every site/function/compute/workflow endpoint has a
project-scoped counterpart under /api/projects/:project/…; the legacy top-level
paths (/api/sites/…, /api/functions/…, /api/compute/…, /api/workflows/…)
target the reserved default project and stay byte-identical to pre-0.2.0.
| Method | Path | Purpose |
|---|---|---|
GET | /api/projects | List projects. |
POST | /api/projects | Create a project. |
GET | /api/projects/:project | Get one project’s record. |
DELETE | /api/projects/:project | Delete an empty project (refused while it owns resources or is default). |
| any | /api/projects/:project/sites/… | Per-project site endpoints — the same shapes as Sites & deployments, scoped to the project. |
| any | /api/projects/:project/{functions,compute,workflows,graphql}/… | Per-project function / compute / workflow / GraphQL-admin endpoints, scoped to the project. |
Sites & deployments
The paths below target the default project; the /api/projects/:project/sites/…
counterparts are identical but scoped to :project.
| Method | Path | Purpose |
|---|---|---|
GET | /api/sites | List sites. |
POST | /api/sites/:site/deployments | Create a deployment from a manifest. |
GET | /api/sites/:site/deployments | List a site’s deployments. |
GET | /api/sites/:site/deployments/:id | Get one deployment. |
POST | /api/sites/:site/deployments/:id/activate | Make a deployment the live one. |
GET | /api/sites/:site/current | The currently active deployment. |
GET/PUT | /api/sites/:site/config | Read / replace the site config. |
GET/PUT/DELETE | /api/sites/:site/aliases/:name | Manage named aliases. |
GET | /api/sites/:site/aliases | List aliases. |
Blobs
| Method | Path | Purpose |
|---|---|---|
PUT | /api/blobs/:hash | Upload a content-addressed blob (raw body; the server verifies the hash). |
Blob-backend migration (node-level)
Complete a zero-downtime blob-backend switch on a managed node reachable only over the
control plane (no fly ssh / local shell). These operate on the node’s own configured
blob store — never a client-named backend — so they are gated at system · admin (the
destructive drain/purge) or system · read (the status probe), like prune / scrub /
sql-move. The singular hyphenated paths (blob-drain, blob-status, blob-purge)
deliberately do not collide with the /api/blobs/… (Blobs · Deploy) content-upload
matcher, so a ship-only publisher can never reach them.
POST /api/blob-drain
Since 0.6.3. Drain the node’s own configured [serve].blob_fallback read-fallback
secondary → primary (Storage::drain_pair on the live composite), so the fallback can be
removed. The client guides; the running daemon (which already holds both backends open)
executes. system · admin.
The body names no source or destination — the daemon drains only its own configured pair:
{ "dry_run": false, "concurrency": 8, "prefix": "" }
All fields are optional (deny_unknown_fields); dry_run defaults to false, concurrency
to the engine default (8), an absent/empty prefix drains every object.
The response is an NDJSON stream (application/x-ndjson) — one JSON object per line — kept
alive over a chunked connection so an edge idle-timeout can’t cut a long copy (the copy is
idempotent and resumable, so a dropped connection is safe to re-run). Progress lines are tagged
"type":"progress"; the final line is tagged "type":"report":
{"type":"progress","done":2,"total":3,"copied":2,"skipped":0,"copied_bytes":4096,"dry_run":false}
{"type":"report","total_objects":3,"copied_objects":3,"skipped_objects":0,"copied_bytes":6144,"verified":true,"dry_run":false,"secondary_drained":true,"message":"SECONDARY FULLY DRAINED — safe to remove [serve].blob_fallback and restart the node."}
secondary_drained is true only on a verified-complete, non-dry-run drain. A verify
failure reports secondary_drained:false plus the missing keys. With no [serve].blob_fallback
configured, the request is a 422 ({ "error": "no blob_fallback configured; nothing to drain" }).
GET /api/blob-status
Since 0.6.4. Query the node’s structured transition-mode state — whether a read-fallback
secondary is currently attached (the node is mid-migration) — instead of grepping the startup
log warning. system · read.
{ "blob_fallback_active": true }
POST /api/blob-purge
Since 0.6.4. Reclaim a provably-safe-only object set on the node’s blob store. Dry-run by
default (report only, delete nothing); apply: true deletes. system · admin.
{ "mode": "unreferenced", "apply": false, "prefix": "" }
deny_unknown_fields. mode is required and is one of:
"unreferenced"— on-demand garbage collection: delete content-addressed blobs no live deploy manifest references (nothing serving can break). Refused with 409 while a read-fallback secondary is attached (drain and drop[serve].blob_fallbackfirst).prefixis ignored in this mode."drained_source"— the migration decommission: delete the read-fallback secondary’s objects that are byte-confirmed present in the primary (fail-closed — an unconfirmed object is kept).prefixrestricts the scope. With no[serve].blob_fallbackconfigured, the request is a 422.
The response is NDJSON (progress lines then a final "type":"report" line), the same shape
as the drain:
{"type":"report","mode":"drained_source","apply":true,"considered":3,"purged":1,"would_purge":0,"skipped_unconfirmed":2,"purged_bytes":17,"message":"drained-source purge complete: reclaimed 1 confirmed-duplicated object(s) (17 byte(s)); kept 2 unconfirmed"}
Domains
| Method | Path | Purpose |
|---|---|---|
GET/POST/DELETE | /api/sites/:site/domains/:host/verification | Manage a domain-ownership challenge. |
POST | /api/sites/:site/domains/:host/verification/check | Check the challenge. |
GET | /api/sites/:site/domain-verifications | List pending verifications. |
Tokens
| Method | Path | Purpose |
|---|---|---|
POST/GET | /api/tokens | Mint / list tokens. |
DELETE | /api/tokens/:id | Revoke a token by its id. |
POST | /api/tokens/bootstrap | Mint the first admin token with the single-use bootstrap secret. |
GET | /api/auth/whoami | The presented token’s own roles. |
POST | /api/auth/exchange | Exchange an OIDC JWT for a short-TTL token (oidc feature). |
Cluster
| Method | Path | Purpose |
|---|---|---|
POST | /api/cluster/join-token | Mint a single-use bearer mesh join token (admin). |
POST | /api/cluster/join | Admit a joining node (gated by the join token in the body + a possession proof, not admin RBAC). |
GET | /api/cluster/members | List the Raft membership (node, voter, caught-up, leader, address). |
POST | /api/cluster/promote | Promote a caught-up learner to a voter (leader-only). |
POST | /api/cluster/rotate-key | Rotate this node’s mesh key (make-before-break). |
POST | /api/cluster/revoke | Revoke a node from the mesh (durable tombstone + drop from quorum). |
See Deploy a self-hosted cluster and Run on Kubernetes.
Root anchors
Make-before-break root-key rotation (auth rotate-root). Admin-scoped.
| Method | Path | Purpose |
|---|---|---|
GET | /api/auth/root | List the extra trusted root anchors. |
PUT | /api/auth/root | Trust a new root anchor ({ "pubkey": "alg:hex" }). |
DELETE | /api/auth/root/:pubkey | Retire a root anchor. |
See Migrate the root key.
Certificates & cache
| Method | Path | Purpose |
|---|---|---|
GET | /api/certs | TLS certificate status. |
POST | /api/cache/invalidate | Invalidate cached responses. |
Operations
| Method | Path | Purpose |
|---|---|---|
GET/POST | /api/prune | Report / delete unreferenced deployments. |
POST | /api/scrub | Delete unreferenced blobs. |
GET | /api/metrics | Prometheus exposition (always available). |
GET/PUT | /api/authz/policy | Read / replace the RBAC policy. |
Functions & workflows
Top-level (default-project) function and workflow endpoints; the
/api/projects/:project/… counterparts scope to another project.
| Method | Path | Purpose |
|---|---|---|
GET | /api/functions | List functions. |
GET/PUT/DELETE | /api/functions/:name | Manage one function (its current version). |
POST | /api/functions/:name/versions | Deploy a new function version. |
POST | /api/functions/:name/rollback | Roll back to a prior version. |
PUT/DELETE | /api/functions/:name/aliases/:label | Manage a version alias. |
POST | /api/functions/:name/invoke | Invoke synchronously / async / scheduled. |
GET | /api/functions/:name/invocations/:id | Get an async invocation record. |
GET/POST/DELETE | /api/functions/:name/triggers[/:id] | Manage event triggers (webhook/queue/cron/blob). |
GET | /api/functions/:name/usage | Metering / quota counters. |
GET/PUT/DELETE | /api/workflows/:name | Manage a declarative workflow. |
GET | /api/workflows/:name/runs[/:id] | List / get workflow runs. |
Compute
Top-level paths target the default project; /api/projects/:project/compute/…
scopes to another project.
| Method | Path | Purpose |
|---|---|---|
GET | /api/compute | List compute workloads. |
GET/PUT/DELETE | /api/compute/:name | Manage one workload. |
POST | /api/compute/:name/exec | Run a command inside a running replica (docker-exec style; posture-gated by allow_compute_exec). Since 0.3.9. |
The control-plane surface is uniform whether or not execution is available on the
node. Only the microVM backend needs /dev/kvm; the native container backend
instead needs the br-boatramp bridge and CAP_NET_ADMIN (both compute backends are
Linux-only). On macOS / Windows compute runs through the remote-docker backend. See
Run compute workloads.
Compute operations (node-global)
These operate across every tenant on the node and are gated at system · admin
— not the per-project /api/compute/* right. Since 0.3.9 (volumes: 0.3.11;
maintenance/diagnostics: 0.3.14).
| Method | Path | Purpose |
|---|---|---|
GET | /api/compute/volumes | List persistent volumes (in-use vs orphaned). |
DELETE | /api/compute/volumes/:name | Reclaim a volume (?force=true to remove one still referenced). |
GET | /api/compute/status | Observed per-replica runtime state (health, lifecycle phase, IP:port, backend). |
GET | /api/compute/ipam | The compute-bridge IP-pool allocation. |
GET | /api/compute/dns | The internal-DNS fleet view. |
POST | /api/compute/dns/resolve | Resolve an internal name as a container would (diagnostic). |
POST | /api/compute/reconcile | Force a reconcile pass. |
POST | /api/compute/maintenance/set-health | Override a replica’s stored health. |
POST | /api/compute/maintenance/restart | Stop + relaunch a replica. |
POST | /api/compute/maintenance/netdiag | Run a network diagnostic. |
See Diagnose compute.
Managed SQL (operator)
Run a migration script or a single query against a managed co-located database via
its sealed credential (resolved server-side). Project-owned (project · deploy);
writes are additionally posture-gated. Top-level paths target the default project;
/api/projects/:project/sql/… scopes to another project. Since 0.3.9 (ping: 0.3.14).
| Method | Path | Purpose |
|---|---|---|
POST | /api/sql/:db/exec | Run a migration / statement script against the managed database :db. |
POST | /api/sql/:db/query | Run a single query and return its rows. |
POST | /api/sql/:db/ping | Active per-replica reachability probe (bypasses the stored-health gate). |
Schema migrations (owner-gated)
Apply an ordered migration step set — function / sql / extension steps — to a
managed database :db, run as the project’s non-superuser owner role and tracked in
a host-owned ledger. Input is upload-then-trigger: the JSON step-set bundle is
uploaded via PUT /api/blobs/:hash and referenced by hash. The mutating verbs are gated
at project · admin (never the deploy-grade publisher right /api/sql/ uses); status
needs only project · read. Top-level paths target the default project;
/api/projects/:project/migrate/… scopes to another project. Since 0.4.25 (Postgres only).
| Method | Path | Purpose |
|---|---|---|
POST | /api/migrate/:db/apply | Apply the pending suffix of the bundle; body { bundle }. project · admin. |
POST | /api/migrate/:db/dry-run | Report which ids would apply, running nothing; body { bundle }. project · admin. |
POST | /api/migrate/:db/baseline | Record the prefix through up_to as already-applied without running it; body { bundle, up_to }. project · admin. |
GET | /api/migrate/:db/status | Read the applied-migration ledger (id, ordinal, hash, kind, applied-at, origin). project · read. |
See Run owner-gated schema migrations.
Provisioning drift-repair (owner-gated)
Converge a managed database’s provisioning against spec — create/seal the non-superuser owner
role, re-own the database + its objects + the migration ledger, re-grant runtime privileges —
idempotently and data-preservingly (boatramp project repair). Deliberately not under
/api/sql/ (whose catch-all resolves to project · deploy, which a ship-only publisher holds
— nesting there would be an escalation); its own /api/repair/* prefix gates both verbs at
project · admin. Dry-run is the report-only posture the CLI uses by default. Top-level paths
target the default project; /api/projects/:project/repair/… scopes to another project. Since
0.5.2 (all SQL backends).
| Method | Path | Purpose |
|---|---|---|
POST | /api/repair/:db/apply | Apply the reconcile — converge provisioning against spec. project · admin. |
POST | /api/repair/:db/dry-run | Drift-detect + report the plan, changing nothing (the default CLI posture). project · admin. |
Managed databases (declarative)
The project-scoped declarative front door onto the daemon-level managed-database provisioning
stack (a per-tenant Postgres/MySQL whose credential boatramp mints and seals). These back
boatramp db ls | get | status and the apply-manifest databases: block; there is no
db create — the manifest is the sole authoring surface. Declaring a database mints owner-role
identities (CREATE ROLE / owner-role DDL as superuser), so the write verbs gate at
project · admin — the same owner-grade placement as /api/migrate/ and /api/repair/, above
the project-deploy catch-all, so a project_publisher / deployer can never reach them; the reads
are project · read. Top-level paths target the default project;
/api/projects/:project/databases/… scopes to another project. Since 0.6.0.
| Method | Path | Purpose |
|---|---|---|
GET | /api/databases | List the project’s declared databases (each an ApplyDatabase). project · read. |
GET | /api/databases/:name | Get one declared database, or 404. project · read. |
PUT | /api/databases/:name | Declare (persist + eagerly provision) a database from an ApplyDatabase body. 204 on success. project · admin. |
POST | /api/databases/:name/ensure | Re-provision an already-declared database idempotently. 204. project · admin. |
GET | /api/databases/:name/status | The stored declaration + a coarse provisioned signal. project · read. |
The PUT body is a typed ApplyDatabase (deny_unknown_fields) — a safe projection that
carries only the fields a project author may declare. Naming an excluded field (image,
password_env / url_env / read_url_env / migration_url_env, path, compute) fails to
parse — those are the security contract (arbitrary-image RCE, BYO-secret/SSRF, host-fs traversal,
author-named workload). The credential is never carried or referenced; boatramp fully manages and
seals it.
{
"name": "app",
"kind": "postgres",
"version": 16,
"size": "medium",
"tenant": "shared",
"tenant_scope": "project",
"extensions": ["pgcrypto"],
"rls_session": true,
"tenant_guc": "app.tenant_id"
}
Only name and kind (postgres / mysql) are required. size is a small / medium /
large preset (default small, → bounded vcpus/mem/volume, not raw VM knobs); tenant is
single (default) / shared; tenant_scope is project (default) / site. The remaining
optional fields — version, extensions, read_only, the RLS knobs (rls_session, tenant_guc,
session_guc, tenant_all_marker), and the capped pool_max / connect_timeout_secs /
startup_grace_secs — are omitted from a response when unset.
GET /api/databases returns a JSON array of ApplyDatabase; GET /api/databases/:name returns
one. GET /api/databases/:name/status wraps the declaration with a workload handle and a coarse
provisioned fact (never a secret):
{
"database": { "name": "app", "kind": "postgres", "size": "medium", "tenant": "shared", "tenant_scope": "project" },
"workload": "bramp-db-<project>-app",
"declared": true
}
The declarative databases: manifest block that drives these routes is covered under
Declare a managed database; see also Apply a manifest.
Secrets & email profiles
Project-scoped credential stores, gated by the secrets right (see
RBAC). A value never leaves over the API — the
list/show responses are metadata-only (secrets) or redacted (email). Present only
when a [secrets] key envelope is configured (else a clear 501). Secrets since
0.3.10; email profiles since 0.3.18.
| Method | Path | Purpose |
|---|---|---|
POST | /api/projects/:project/secrets | Set (seal) a secret { name, value }; returns metadata, never the value. |
GET | /api/projects/:project/secrets | List secret names + metadata (no values). |
DELETE | /api/projects/:project/secrets/:name | Delete a secret. |
PUT | /api/projects/:project/email/profiles/:name | Set / partial-update an SMTP profile (sealed password); returns the redacted profile. |
GET | /api/projects/:project/email/profiles[/:name] | List / show email profiles (redacted). |
DELETE | /api/projects/:project/email/profiles/:name | Delete an email profile. |
See Store secrets and Send email.
Tenancy schema
The project’s per-table tenant-key map — the isolation boundary that scopes every
guest query. Read with project · read; replace/clear with project · admin
(above the publisher’s deploy right, so a publisher cannot redraw the boundary).
Since 0.4.3.
| Method | Path | Purpose |
|---|---|---|
GET | /api/projects/:project/tenancy | Read the tenancy schema. |
PUT | /api/projects/:project/tenancy | Replace the tenancy schema. |
DELETE | /api/projects/:project/tenancy | Clear it (back to deny-by-default). |
See Isolate tenants.
GraphQL
The subgraph registry, the operation safelist, and the composed supergraph — a
project-owned surface. Top-level paths target the default project;
/api/projects/:project/graphql/… scopes to another project. See
Serve a GraphQL API.
| Method | Path | Purpose |
|---|---|---|
PUT/DELETE | /api/graphql/subgraphs/:name | Register (SDL body) / unregister a subgraph; a publish recomposes and is rejected if it doesn’t compose. |
PUT | /api/graphql/subgraphs/:name/sql | Register a SQL-backed subgraph by introspecting a site’s managed database. |
PUT | /api/graphql/subgraphs/:name/function | Register a function-backed subgraph by introspecting its _service { sdl }. |
GET | /api/graphql/supergraph | The composed supergraph (subgraphs, @key entities, root fields). |
POST/GET | /api/graphql/safelist | Register a trusted operation (returns its hash) / list the safelist. |
DELETE | /api/graphql/safelist/:hash | Remove an operation from the safelist. |
A function that self-declares a subgraph auto-registers on deploy; pass
?register_subgraph=false to PUT /api/functions/:name to opt a deploy out. See
Federation.
Observability
Present with the handlers feature.
| Method | Path | Purpose |
|---|---|---|
GET | /api/sites/:site/_boatramp/handlers | Per-handler operator stats. |
GET | /api/sites/:site/_boatramp/logs | Captured per-site guest logs. |
GET | /api/sites/:site/_boatramp/logs/stream | Stream per-site logs (SSE). |
POST | /api/sites/:site/_boatramp/dlq | Dead-letter-queue operations. |
GET/POST | /api/projects/:project/_boatramp/bus/dlq | Shared project-bus DLQ: inspect (Project·Read) / purge·redrive·discard (Project·Admin). Since 0.4.24. |
GET | /api/projects/:project/_boatramp/bus/queue/{peek,replay,groups} | Project-bus live-queue inspection (Project·Read). Since 0.4.24. |
POST | /api/projects/:project/_boatramp/bus/queue/{group,pause} | Project-bus group reset·delete / pause·resume (Project·Admin). Since 0.4.24. |
GET | /api/functions/:name/_boatramp/logs | Captured per-function guest logs (project-owned read). Since 0.3.17. |
GET | /api/functions/:name/_boatramp/logs/stream | Stream per-function logs (SSE). Since 0.3.17. |
The function-logs endpoints have a /api/projects/:project/functions/… counterpart
scoped to another project. See Observe a running server.
Agent (MCP)
| Method | Path | Purpose |
|---|---|---|
POST/GET/DELETE | /mcp | Model Context Protocol endpoint (streamable-http), for driving this node from an AI agent. |
Unlike /api/*, /mcp is gated only by a valid plain bearer (not a specific
right): each MCP tool call is separately re-authorized in-process against the
forwarded token’s scope. On by default; toggle with mcp.enabled
(daemon config). cnf/DPoP tokens are rejected — use a plain
bearer or the stdio transport.
Public (unauthenticated) endpoints
Never token-authenticated. Visitor access control (basic auth / IP rules / rate limit) is applied per-site inside the serving handlers.
| Method | Path | Purpose |
|---|---|---|
GET | /healthz | Liveness. |
GET | /readyz | Readiness. |
| any | / (host-routed) | Serve site content, selected by Host — see How a request reaches your site. |
| any | /_sites/<name>/* | Serve a site by name (admin/testing). |
GET | /_deploy/* | Serve a deployment by id (an unguessable content-hash capability). |
RBAC roles, actions & resources
The control-plane API authorizes every request against a set of rights. A
right is an action on a resource, optionally scoped to
a project or a <project>/<site>. A token carries one or
more granted roles; a role expands to a set of rights. A request
is allowed when a held right satisfies the right the request requires.
For issuing and verifying tokens, see Bootstrap authentication and Make a scoped CI deploy token; for the design, see Authentication & authorization.
Actions
| Action | Meaning |
|---|---|
read | Read and list (GET endpoints). |
write | Mutate configuration: site config, aliases, domain verification, cache. |
deploy | Ship content: create and activate deployments, upload blobs. |
admin | Full control of the resource. |
Only admin implies the others: a held admin right on a resource satisfies a
required read, write, deploy, or admin on that same resource. The other
three actions are independent. Implication is per-resource — admin on tokens
does not satisfy any right on site.
Resources
Three resources are target-scoped: site (target <project>/<site>), project
(target <project>, since 0.2.0), and secrets (target <project>, since 0.3.10).
The other five are global.
| Resource | Scoped | Governs |
|---|---|---|
site | <project>/<site> | Per-site deployments, config, aliases, domain verification, per-site observability. |
project | <project> | The project entity plus the resources it owns — its functions, compute workloads, workflows, and GraphQL admin surface (subgraph registry + safelist). A project grant is the tenant boundary: a token scoped to one project cannot touch a sibling. |
secrets | <project> | The project’s sealed secret store — its boatramp:<name> values and SMTP email profiles. Separate from project so managing credentials is a distinct, admin-gated right, auditable on its own (since 0.3.10). |
blobs | global | Content-addressed blob uploads. |
tokens | global | API token management. |
certs | global | TLS certificate status. |
cache | global | Cache invalidation. |
system | global | Metrics, prune, scrub, site/project listing, cluster membership, authz policy. |
Default roles
The built-in policy defines eight roles. A grant marked (site) binds to the role
instance’s <project>/<site> target; (project) binds to its <project> target;
(project/*) is a wildcard over every site in the bound project; (any) is a
global right.
| Role | Scoped | Grants |
|---|---|---|
admin | global | admin on every resource. |
publisher | site | read, write, deploy on site (site); deploy on blobs (any). |
deployer | site | read, deploy on site (site); deploy on blobs (any). No config write. |
viewer | site | read on site (site). |
operator | global | read on system (any); read on certs (any); write on cache (any). No site access. |
project_admin | project | admin on project (project); admin on secrets (project); admin on site (project/*); deploy on blobs (any). Full control of one project and everything it owns — including managing its sealed secrets and email profiles (a publisher can only reference a boatramp:<name>, not manage it). |
project_publisher | project | read, write, deploy on project (project) and on site (project/*); deploy on blobs (any). Ships sites/functions/compute in the project; cannot admin the project entity. |
project_viewer | project | read on project (project) and on site (project/*). Read-only across one project. |
An unknown role name grants nothing — it is ignored, not an error.
Scoping
A granted role is written <role> (global) or <role>:<target> (bound). The
suffix after the first : is the target; an empty suffix parses as global. A
site role’s target is <project>/<site>; a project role’s target is a bare
<project>.
| Spec | Interpretation |
|---|---|
admin | Global admin. |
publisher:acme/blog | publisher bound to site blog in project acme. |
viewer:acme/docs | viewer bound to site docs in project acme. |
project_admin:acme | project_admin bound to project acme (and every site it owns). |
project_viewer:acme | read-only across project acme. |
Back-compat: a legacy bare site target (publisher:blog, no project segment) is
normalized to the reserved default project (publisher:default/blog) before the
decision, so pre-0.2.0 tokens keep working unchanged.
Granting a site- or project-scoped role without a target (e.g. publisher with
no :target) drops its scoped rights — a global publisher grants only its blobs
right. Target matching is exact; a global (wildcard) grant covers every site. A
project_* role covers every site in its bound project via a <project>/* wildcard.
A token carries a list of granted roles; the rights it confers are the union of
each role’s expanded rights. A token minted with --role publisher:acme/blog --role viewer:acme/docs may write acme/blog, read acme/docs, and upload blobs.
Request-to-right mapping
Each control-plane endpoint requires exactly one right. A few endpoints require
no right and are gated by their own single-use credential instead. Any unmapped
/api/* path falls through to system · admin (deny-safe), so a narrow token
can never reach an ungated action.
Site and project targets below are the values the right is scoped to. A legacy
/api/sites/<site>/… path scopes to default/<site>; a /api/projects/<proj>/…
path scopes to <proj> (or <proj>/<site> for its sites).
| Method | Path | Required right |
|---|---|---|
POST | /api/auth/exchange | none (carries an IdP JWT) |
GET | /api/auth/whoami | none (any valid token) |
POST | /api/tokens/bootstrap | none (bootstrap secret) |
POST | /api/cluster/join | none (single-use join token) |
PUT | /api/blobs/<hash> | blobs · deploy |
GET | /api/sites | system · read |
GET | /api/projects | system · read |
POST | /api/projects | system · admin |
GET | /api/projects/<proj> | project · read (proj) |
DELETE | /api/projects/<proj> | project · admin (proj) |
GET | /api/projects/<proj>/{functions,compute,workflows,graphql}[/…] | project · read (proj) |
POST/PUT/DELETE | /api/projects/<proj>/{functions,compute,workflows,graphql}/… | project · deploy (proj) |
GET | /api/projects/<proj>/secrets | secrets · read (proj) |
POST/DELETE | /api/projects/<proj>/secrets[/<name>] | secrets · write (proj) |
GET | /api/projects/<proj>/email/profiles[/<name>] | secrets · read (proj) |
PUT/DELETE | /api/projects/<proj>/email/profiles/<name> | secrets · write (proj) |
GET | /api/projects/<proj>/tenancy | project · read (proj) |
PUT/DELETE | /api/projects/<proj>/tenancy | project · admin (proj) — mutating the tenant-isolation boundary is owner-level, above the publisher’s deploy |
POST | /api/[projects/<proj>/]sites/<site>/deployments | site · deploy (target) |
GET | /api/[projects/<proj>/]sites/<site>/deployments[/<id>] | site · read (target) |
POST | /api/[projects/<proj>/]sites/<site>/deployments/<id>/activate | site · deploy (target) |
GET | /api/[projects/<proj>/]sites/<site>/config | site · read (target) |
PUT | /api/[projects/<proj>/]sites/<site>/config | site · write (target) |
PUT/DELETE | /api/[projects/<proj>/]sites/<site>/aliases/<name> | site · write (target) |
GET | /api/{functions,compute,workflows,graphql}[/…] (legacy) | project · read (default) |
POST/PUT/DELETE | /api/{functions,compute,workflows,graphql}/… (legacy) | project · deploy (default) |
GET | /api/functions/<name>/_boatramp/logs[/stream] | project · read (default; proj for the project-scoped path) |
POST | /api/compute/<name>/exec | project · deploy (default) — additionally gated at the handler by the allow_compute_exec posture |
POST | /api/sql/<db>/{exec,query,ping} | project · deploy (default) — SQL writes are additionally posture-gated |
GET/DELETE | /api/compute/volumes[/<name>] | system · admin (node-global — not the per-project /api/compute/* right) |
GET/POST | /api/compute/{status,ipam,dns[/resolve],reconcile} | system · admin (node-global — not the per-project /api/compute/* right) |
POST | /api/compute/maintenance/{set-health,restart,netdiag} | system · admin (node-global — not the per-project /api/compute/* right) |
POST/DELETE | /api/tokens[/<id>] | tokens · admin |
GET | /api/certs | certs · read |
POST | /api/cache/invalidate | cache · write |
GET | /api/metrics | system · read |
GET/POST | /api/prune, /api/scrub | system · admin |
| any | /api/authz/* | system · admin |
| any | other /api/* | system · admin (deny-safe) |
Node-global compute endpoints are system · admin, never the per-project
/api/compute/* right. The persistent-volume (compute/volumes[/<name>]) and
maintenance/diagnostic (compute/{status,ipam,dns,reconcile} and
compute/maintenance/*) surfaces operate across every tenant on the node — they
enumerate and remove volumes, and observe and override the reconcile plane, for all
projects. They are gated explicitly at system · admin, above the general
/api/compute/* mapping that a project-scoped grant would otherwise satisfy, in both
the direct (/api/compute/…) and project-scoped (/api/projects/<proj>/compute/…)
path forms — so a project token can never reach another tenant’s volumes or state.
The policy document
The role-to-rights mapping is data, stored as JSON at the KV key authz/policy
(schema v1). When the key is absent the built-in default above applies. A
replacement is validated server-side and rejected if invalid, so a bad policy
cannot brick the control plane. Editing it requires an admin token:
boatramp auth policy get # print the active policy as JSON
boatramp auth policy set policy.json # validated server-side before storing
DNS providers & credentials
The managed-DNS providers boatramp drives directly, and the manual fallback.
Ten providers are built in. Each entry lists the value passed to --provider,
any accepted alias, and the exact credential environment variables the provider
reads.
Credentials are read from the environment only — never from a config file. The
same --provider names apply in every DNS command surface:
boatramp dns --provider <name>, boatramp serve --acme-dns-provider <name>,
and boatramp domain add --provider <name>.
Providers
--provider | Alias | Provider | Credential env vars |
|---|---|---|---|
manual | — | none (prints records) | — |
cloudflare | — | Cloudflare | CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN |
route53 | — | AWS Route 53 | ROUTE53_HOSTED_ZONE_ID + the standard AWS chain |
oci | — | Oracle Cloud DNS | OCI_REGION, OCI_ZONE, OCI_KEY_ID, OCI_PRIVATE_KEY_FILE |
digitalocean | do | DigitalOcean | DIGITALOCEAN_DOMAIN, DIGITALOCEAN_TOKEN |
hetzner | — | Hetzner DNS | HETZNER_ZONE_ID, HETZNER_ZONE, HETZNER_DNS_TOKEN |
ns1 | — | NS1 (IBM) | NS1_ZONE, NS1_API_KEY |
dnsimple | — | DNSimple | DNSIMPLE_ACCOUNT_ID, DNSIMPLE_ZONE, DNSIMPLE_TOKEN |
gcp-dns | gcp | Google Cloud DNS | GCP_DNS_PROJECT, GCP_DNS_ZONE, GCP_ACCESS_TOKEN |
azure-dns | azure | Azure DNS | AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, AZURE_DNS_ZONE, AZURE_ACCESS_TOKEN |
akamai | — | Akamai Edge DNS | AKAMAI_HOST, AKAMAI_CLIENT_TOKEN, AKAMAI_CLIENT_SECRET, AKAMAI_ACCESS_TOKEN, AKAMAI_ZONE |
Notes
manualprints the records to apply by hand and reads no credentials. It is the fallback for self-hosted authoritative servers (BIND, PowerDNS, Knot).gcp-dnsandazure-dnstake a short-lived OAuth2 access token inGCP_ACCESS_TOKEN/AZURE_ACCESS_TOKEN. Mint it withgcloud/az.route53readsROUTE53_HOSTED_ZONE_IDfor the zone and resolves credentials through the standard AWS provider chain (environment, shared config, instance role).
See also
Cargo features & platform support
boatramp is one binary of feature-gated crates, but the default build is
batteries-included: it enables every non-conflicting feature, so a plain
cargo build, the Nix/OCI images, and the release binaries all ship the full
capability set — there is no “the server was built without X”. This page lists the
cargo features (all default-on) and then which capabilities are Linux-only. To
build a minimal binary instead, see Build from source.
Cargo build features
Every feature below is on by default. The whole set composes (there are no
mutually-exclusive features; the runtime --blobs / --kv / --tls selectors
pick among the compiled-in backends), and the nightly --all-features gate proves
it. Should a feature ever genuinely conflict with another, it would be dropped
from the default and shipped as its own build variant.
For a minimal build, opt out and name only what you want:
cargo build --release -p boatramp --no-default-features --features fs,slatedb
Some features imply others: http3/acme-dns imply tls; cluster implies
handlers + slatedb; operator implies cluster; each sql-* and
orm-subquery imply handlers.
| Feature | Default | Enables |
|---|---|---|
fs | yes | Filesystem blob backend (--blobs fs). |
slatedb | yes | The default --kv slatedb: a durable transactional LSM over an object_store backend. |
s3 | yes | S3 blob backend (--blobs s3) + its S3→SQS blob-change notification provider. |
gcs | yes | Google Cloud Storage blob backend (--blobs gcs) + its GCS→Pub/Sub notification provider. |
azure | yes | Azure Blob Storage backend (--blobs azure) + its Event Grid→Storage Queue notification provider. |
cloudflare-kv | yes | Cloudflare KV metadata backend. |
tls | yes | HTTPS: --tls custom (operator cert) and --tls acme (automatic certs). |
acme-dns | yes | Wildcard TLS via ACME DNS-01 plus the dns subcommand (--tls acme-dns) and the pluggable DNS-provider clients. Implies tls. |
http3 | yes | HTTP/3 (QUIC) serving alongside the TLS TCP listener. Implies tls. |
oidc | yes | OIDC → token exchange: verify serve against an OIDC issuer’s JWKS. |
signer-aws | yes | External token signer backed by AWS KMS. |
signer-gcp | yes | External token signer backed by GCP KMS. |
signer-azure | yes | External token signer backed by Azure Key Vault. |
signer-vault | yes | External token signer backed by HashiCorp Vault. |
signer-pkcs11 | yes | External token signer backed by a PKCS#11 HSM. |
compression | yes | On-the-fly response compression, opt-in per site. |
bundler | yes | The in-process JS/TS + CSS bundler for boatramp bundle. |
handlers | yes | The wasmtime handler engine, component validation at sync, and the sql handler binding (with the typed orm query builder over the same databases). |
cluster | yes | Self-hosted Raft cluster mode. Implies handlers and slatedb. |
sql-postgres | yes | External (bring-your-own) PostgreSQL for the handler sql binding, opened by name. Implies handlers. |
sql-mysql | yes | External (bring-your-own) MySQL/MariaDB for the handler sql binding, opened by name. Implies handlers. |
orm-subquery | yes | The typed orm builder’s correlated roll-up (related_count/related_agg) and narrow scalar/IN subqueries — the one scalar-subquery form. Implies handlers; drop it to ship a host that refuses correlated subqueries. (pgvector distance and the rest of the orm surface are always compiled — not a cargo feature; pgvector is advertised to guests as the experimental orm-vector capability — a requires gate, not a build feature.) |
email | yes | The per-project SMTP email gateway: the boatramp:handlers/email guest capability + the boatramp email admin subcommand (host-held credentials, durable spool). Implies handlers; drop it for a build with no SMTP client. |
admin | yes | The guest project self-config capability boatramp:handlers/admin — a guest reconfigures its own project (domains / email / site / secrets) without a token, per-surface and posture-gated. Implies handlers; drop it for a build with no guest self-config. |
capability | yes | The guest capability-minting capability boatramp:handlers/capability — a guest mints a fleet-signed, own-project-only target capability (audience host-forced, TTL-clamped, deny-by-default). Implies handlers; drop it for a build with no guest capability minting. |
session | yes | The duplex guest session capability boatramp:handlers/session — a host-owned SSE-out + POST-in channel with host-managed ordering, resume, and lifetime. Implies handlers. |
console | yes | Bake the web management console (a Wasm SPA) into the binary; serve it at an operator-configured host+path ([serve.console]). On in every shipped build (release binaries + Nix/OCI images), which stage the built SPA in; a from-source build embeds a placeholder unless you build the SPA first with just console. |
mcp | yes | The Model Context Protocol server: the boatramp mcp stdio subcommand + the HTTP /mcp endpoint on serve. Also enables boatramp-server/mcp. |
Two more features are on by default but omitted from the table above:
domain-verify-dns (verify a host’s _boatramp-verify TXT over public DNS) and
operator (the in-binary Kubernetes operator; implies cluster).
The COSE/CWT + Cedar control-plane auth, the OCI→ext4 rootfs build, and the container / microVM / remote-docker compute backends are compiled into every build; they are not behind cargo features. The compute code that needs Linux is gated at the source level and compiles to no-ops elsewhere.
# A minimal build (filesystem blobs + embedded KV only).
cargo build --release -p boatramp --no-default-features --features fs,slatedb
Platform support
The publish / serve / handler / TLS / cluster core is cross-platform. The compute execution backends differ:
| Platform | Compute backends |
|---|---|
Linux x86_64, aarch64 | microVM (needs /dev/kvm), native container, remote-docker |
| macOS, Windows | remote-docker only |
The microVM and native-container backends need /dev/kvm, namespaces, and the
jailer, so they are Linux-only; on macOS and Windows that code compiles to
no-ops and compute runs through the remote-docker backend against a Linux Docker
host.
See also
- Build from source — toolchain and selecting features at build time.
- Install boatramp — prebuilt archives and packages.
Metrics & access-log fields
boatramp exports Prometheus metrics and a structured access log from the same serving path. This page lists the exported metrics and the access-log fields. For how to scrape and read them, see Observe a running server.
The Prometheus exporter at /api/metrics is admin-scoped. The handler and
consumer metrics are present only when the binary is built with the handlers
feature.
Prometheus metrics
Exported at /api/metrics.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
boatramp_http_requests_total | counter | status_class, cache_result | Requests by status class (2xx / 3xx / …) and cache result. |
boatramp_http_response_bytes_total | counter | — | Total response body bytes streamed. |
boatramp_deployments_total | counter | — | Deployment manifests created. |
boatramp_activations_total | counter | — | Activations (live / alias pointer flips). |
boatramp_cert_renewals_total | counter | — | ACME certificate issues and renewals. |
boatramp_daemon_config_info | gauge | generation | Always 1; the generation label is the active dynamic-config content address (none on the pure file baseline). Scrape it fleet-wide to confirm every node converged. |
With the handlers feature the exporter also renders per-(site, trigger, route) handler-invocation counters and per-consumer queue-depth and dead-letter
gauges.
Access-log fields
Every request is logged on the boatramp::access tracing target. Set
BOATRAMP_LOG_FORMAT=json for a machine-readable sink; verbosity follows
RUST_LOG (default boatramp=info).
| Field | Meaning |
|---|---|
method | HTTP request method. |
path | Request path. |
host | Request host. |
client_ip | Client IP address. |
status | Response status code. |
bytes | Response body bytes. |
encoding | Content encoding applied to the response. |
cache_result | Cache outcome for the request (see below). |
duration_ms | Time taken to serve the request, in milliseconds. |
cache_result values
| Value | Meaning |
|---|---|
full | Served fully from cache. |
partial | Partial-content (Range) response. |
not-modified | Conditional request answered 304. |
redirect | Answered with a redirect. |
error | Answered with an error. |
KV Keyspace
The authoritative map of every key boatramp writes, across its two backends.
Prefixes are distinct and slash-delimited so a list_prefix scan enumerates one
kind without matching another.
- Storage (fs / S3 / R2) — blob content.
- KV (SlateDB / memory / Cloudflare KV; or
RaftKvin cluster mode) — all control-plane metadata.
Storage (blob content)
| Key | Value |
|---|---|
<2>/<sha256> | raw file bytes, sharded by the first 2 hex chars of the hash (e.g. ab/abcdef…) |
Blobs are content-addressed and immutable: the key is the SHA-256. boatramp scrub re-hashes each to detect drift.
KV (control plane)
Since 0.2.0 the keyspace splits three ways under the project re-keying (a migration — see Upgrade a store to project scoping):
- Project-scoped — every mutable per-name record lives under
project/<proj>/…. Pre-project resources migrate to the reserveddefaultproject, so they land underproject/default/…. The owning project is always part of the key. - Global content-addressed — dedup-shared immutable bodies keyed by their own hash. A content hash is a self-authenticating capability, so these bodies dedup across all projects (GC unions reachability over every project before it collects one).
- Global-uniqueness index — the domain-routing index. The key stays global
(a host is globally unique), but its value now carries the owning
(project, site).
Global — content-addressed bodies & singletons
| Key | Value |
|---|---|
manifests/<id> | a deployment Manifest (file→hash map + DeployConfig); <id> is its content hash |
meta/<id> | DeployMeta (created-at, sizes, source/branch/author/message) |
siteconfig/<hash> | immutable content-addressed SiteConfig body (dedups across sites & projects) |
daemonconfig/<hash> | immutable content-addressed dynamic-daemon-config body |
projectver/<hash> | immutable content-addressed project spec body |
projectmeta/<proj> | mutable pointer → the hash of the project’s current spec |
owner/<kind>/<name> | reverse index: a resource (kind, name) → its owning project (single-membership guard) |
authz/policy | the RBAC policy (roles → rights); absent ⇒ the built-in default |
authz/tokens/<id> | issued-token metadata (label, roles); the token is never stored |
authz/revoked/<id> | a revocation marker (presence ⇒ revoked) |
auth/root/<alg:hex> | an extra trusted root anchor (auth rotate-root, make-before-break) |
cert/<domain> | a stored cert (chain + key + expiry) — cluster-managed |
Global — domain-routing index (key global, value carries the owner)
| Key | Value |
|---|---|
domain/<host> | exact host → DomainOwner { project, site } (a bare-string value is read as the default project, back-compat) |
wildcard/<suffix> | wildcard suffix → DomainOwner { project, site } |
httpchallenge/<host>/<token> | O(1) index for the self-serve HTTP-01 edge route → the owning (project, site) |
Project-scoped (project/<proj>/…, mutable per-name)
| Key | Value |
|---|---|
project/<proj>/current/<site> | the live deployment id for a site |
project/<proj>/history/<site> | the site’s activation log |
project/<proj>/alias/<site>/<name> | a named alias → deployment id |
project/<proj>/site/<site> | mutable pointer → the hash of the site’s current SiteConfig |
project/<proj>/domainverify/<site>/<host> | a pending domain-ownership challenge |
project/<proj>/dnsmanaged/<site>/… | managed-DNS reconciliation state |
project/<proj>/functions/<name> | a function’s metadata (current version pointer) |
project/<proj>/functions/<name>/versions/<id> | an immutable function version |
project/<proj>/functions/<name>/alias/<label> | a function alias → version |
project/<proj>/functions/<name>/triggers/<id> | an event trigger (webhook/queue/cron/blob) |
project/<proj>/functions/<name>/invocations/<id> | an async invocation record |
project/<proj>/functions/<name>/idem/<key> | an idempotency marker |
project/<proj>/metering/<name> | a function’s usage/quota counters |
project/<proj>/blobnotify/<function>/… | blob-change watch state |
project/<proj>/compute/<name> | a compute workload spec pointer |
project/<proj>/compute_state/<workload>/<replica> | a replica’s lifecycle/snapshot state |
project/<proj>/workflows/<name> | a declarative workflow definition |
project/<proj>/workflows/<name>/runs/<id> | a workflow run |
project/<proj>/secret/<name> | a sealed internal secret value (boatramp:<name>; sealed at rest by the [secrets] key envelope, 0.3.10) |
project/<proj>/email/<name> | a sealed SMTP email profile (relay config + sealed password, 0.3.18) |
Mesh membership (cluster mode, replicated)
The dynamic-join trust + routing state, replicated through the control plane so every node (and a restart) converges. See Deploy a self-hosted cluster.
| Key prefix | Value |
|---|---|
mesh/trust/<node>/<pubkey> | an accepted mesh public key (the sole authority on who may speak on the mesh) |
mesh/addr/<node> | a member’s advisory mesh URL (routing; the TLS re-authenticates by key) |
mesh/revoked/<pubkey> | a durable revocation tombstone — a fresh token can’t re-admit this key until un-revoked (F6) |
mesh/join/used/<jti> | a spent single-use join-token handle (makes admission single-use) |
Messaging (handler wasi:messaging)
| Key prefix | Value |
|---|---|
mq/<topic>/<id> | a queued record |
mqp/<topic>/<id> | in-flight (claimed) marker |
mqdead/<topic>/<id> | a dead-lettered record |
The <topic> is project-qualified for a non-default project (<proj>/<topic>),
so two projects’ same-named topics stay isolated; the default project’s topics are
unprefixed (byte-identical to pre-0.2.0).
Sessions (handler boatramp:handlers/session)
| Key prefix | Value |
|---|---|
session/<project>/<id> | a duplex guest-session record (host-stamped principal, project, route + ordering/resume state, 0.4.2) |
The <project> and <id> are host-stamped (never guest-forgeable), so a session is
isolated to the project that opened it; the session reaper scans session/<project>/
to expire records.
Cluster Raft store (cluster mode only)
Each node’s durable local KV, distinct from the replicated control plane it serves:
| Key | Value |
|---|---|
raft/vote | the node’s current vote |
raft/committed, raft/purged | log progress markers |
raft/log/<index:020> | a Raft log entry |
raft/sm/last_applied, raft/sm/membership | applied-state metadata |
raft/sm/d/<key> | applied state-machine data (mirrors the control-plane keys) |
raft/snapshot | the latest snapshot |
Immutable vs mutable
Content-addressed keys (manifests/<id>, siteconfig/<hash>, projectver/<hash>,
blobs) are immutable — cached forever, never in the
cache-coherence feed. Only mutable
pointers/config (project/<proj>/current/, project/<proj>/site/, domain/,
projectmeta/, authz/tokens/, cert/) need invalidation. Coordination state
(ratelimit/, mqp/) is never cached.
Errors & exit codes
Exit codes
The boatramp CLI uses the two standard shell exit codes:
| Code | Meaning |
|---|---|
0 | Success. |
1 | Any error. |
On failure the CLI prints the error and its cause chain to stderr, then exits
1:
error: failed to publish site "blog"
caused by: server returned 403 Forbidden
caused by: token lacks required right site:blog · deploy
The top line is the command-level error; each caused by: is one link deeper in
the underlying cause, so the root cause is the last line. Scripts should branch on
the exit code (0 vs non-zero) rather than parse the message text.
(The one place a different code appears is the internal container/VMM sandbox worker, which propagates the guest’s exit status — not a surface a user invokes.)
API status codes
When the CLI talks to a server, an HTTP error is surfaced in the cause chain above. The control-plane API uses conventional statuses:
| Status | Meaning | Common cause |
|---|---|---|
400 | Bad request | Malformed body, or an invalid authz policy. |
401 | Unauthenticated | Missing, malformed, expired, or revoked token. |
403 | Forbidden | Valid token without the required right. |
404 | Not found | Unknown site, deployment, or alias. |
409 | Conflict | State precondition failed (e.g. activating a nonexistent deployment). |
413 | Payload too large | Upload exceeds BOATRAMP_MAX_UPLOAD_BYTES. |
429 | Too many requests | Rate limit or upload-concurrency cap reached. |
503 | Unavailable | Upload slots exhausted, or the node is not ready. |
A non-2xx response carries a JSON { "error": "..." } body, which becomes the
deepest caused by: line.
Validation errors
boatramp validate (and sync, which validates first) reports config problems
against project.cfg before anything is published — a bad route pattern, an
unknown handler import, an unparsable cron schedule, or a credential-shaped value
in a handler env. These fail at deploy time, not request time:
error: project.cfg: handler /api env var "TOKEN" looks like a secret; move it to
[handlers].secrets as a reference to a host env var
See the routing schema for the fields these checks cover.
Store migration
boatramp serve refuses to start on a control-plane store still on the pre-0.2.0
layout, rather than silently reading it under the project-scoped keys. Migrate the
store explicitly with boatramp migrate (or start serve --auto-migrate):
error: the control-plane store is not migrated to the project-scoped (0.2.0)
layout; run `boatramp migrate` first, or start `serve --auto-migrate`
See Migrate to projects.
Resource-name validation
A project, site, function, compute, or workflow name is rejected at the write
boundary (CLI or API) if it contains a path separator (/ or \), a *,
whitespace, or an ASCII control character, or if it is . or ... This keeps a
name from escaping its key prefix or aliasing an authz wildcard, so the create or
update fails before anything is written.
Glossary
The canonical term for each concept, used consistently across these docs. Where a concept has a fuller treatment, the definition links to it.
Sites & content
Site — a named project boatramp serves. The unit that owns domains, config, and deployments.
Project — the Workspace (Uchron term) that owns many sites, functions, and compute, and is the tenant boundary for a managed handler’s row-level scope. Every resource belongs to exactly one project; a site name is unique only within its project. See Organize sites into a project.
default project — the reserved project holding all pre-0.2.0 resources and
anything deployed without an explicit --project; byte-identical to
single-project behaviour, and cannot be deleted.
Deployment — an immutable published version of a site’s content, identified by a content hash. A deployment is created, then activated; it never changes in place.
Activation — flipping a site’s current pointer to a deployment, making it the live one. The reverse is a rollback (activating an earlier deployment).
Current — the deployment a site serves by default. One per site.
Manifest — the path→hash map that defines a deployment’s content, plus its folded-in routing config.
Blob — the content-addressed bytes of one file, stored once and referenced by hash. Identical files across deployments share a blob.
Alias — a named pointer to a deployment besides current (e.g. staging),
used for previews and opt-in background work.
Preview — a deployment served by its id at /_deploy/<id> before (or instead
of) activation. The id is an unguessable content hash.
Compute
Handler — a WebAssembly component bound to a route, run in an in-process sandbox. See the compute model.
Component — a wasm32-wasip2 WebAssembly component: the artifact a handler,
consumer, or stream runs.
Consumer — a message-triggered handler, invoked once per message on a topic.
Cron — a scheduled invocation of a handler route.
Stream — a host-level SSE or WebSocket endpoint that fans out messaging topics to connected clients.
Import — a host capability a handler requests (wasi:keyvalue, sql, …),
granted only if the site’s allowlist permits it.
External database — an operator-configured Postgres/MySQL a handler or
function opens by name through the sql binding (bring-your-own), as opposed to
the managed per-site libsql default. Isolation is the operator’s. See
Use handler bindings.
Compute (workload) — container or microVM execution, distinct from an in-process handler. Needs KVM on the host. See Run compute workloads.
Routing & serving
The gateway — the reverse proxy and load balancer that publishes private upstream services through a site. See Expose a private service.
Request pipeline — the fixed ordered stages every served request runs through. See The request pipeline.
Security posture — the operator profile (multi-tenant / single-tenant / dev) plus overrides that set the security defaults. See Security posture.
Control plane & auth
Control plane — the authenticated management API (publishing, config, tokens). Distinct from public content serving, which is unauthenticated. See the API reference.
Token — a signed, offline-verifiable credential (COSE_Sign1 over a CWT) that
carries granted roles. See Authentication & authorization.
Role / action / resource / right — the RBAC vocabulary. A role expands to rights; a right is an action on a resource, optionally site-scoped.
Signer — the seam that holds the token signing key: a local key, a cloud KMS, Vault, or a PKCS#11 HSM. See external signer.
Delegation / attenuation — narrowing a token offline into a further-scoped child, with no server round-trip. A child can only add restrictions.
Proof-of-possession (PoP / DPoP) — a token bound to a holder key (cnf) whose
private half never travels with the token; the client signs a fresh per-request
proof, so a leaked token alone is inert. See PoP-bind a token.
Storage & topology
Storage / KvStore — the two backend seams: Storage for blobs, KvStore for
metadata. Swapping either swaps a backend without changing the CLI. See
Deployment topologies.
Node — one boatramp serve process.
Cluster — a set of nodes replicating the control plane over Raft.
Voter / learner — a Raft node that counts toward quorum (voter) or serves local reads and forwards writes without voting (learner).
Mesh — the raw-public-key mutual-TLS network between cluster nodes. See cluster mesh certificates.
Contributing
Want to say hi or talk something through first? Join us on Discord.
boatramp is a Rust workspace. The default build is batteries-included — it enables
every non-conflicting feature (TLS, ACME DNS-01, clustering, handlers, OIDC,
compression, HTTP/3, the bundler, …), so a plain cargo build ships the full
capability set. For fast local iteration you can opt down to a minimal slice.
Building & testing
cargo build # batteries-included (all features)
cargo build --no-default-features --features fs,slatedb # a fast, minimal slice
cargo test --workspace # the full suite
cargo clippy --workspace --all-targets -- -D warnings
cargo deny check # advisories / bans / licenses / sources
When you touch a feature-gated area, run clippy with that feature too — e.g.
cargo clippy -p boatramp-server --features handlers,oidc,compression --all-targets -- -D warnings. The pre-commit hooks run clippy, rustfmt, taplo,
and typos.
Principles
- Streaming-first. No byte path may buffer a whole file in memory.
- One UX across deploy targets. Environment differences live behind the
Storage/KvStore/Messagingtrait seams, never in the commands, flags, or config. - Complete implementations. Prefer real, validated code over stubs.
- Batteries-included, cleanly gated. Heavy capabilities are still cargo
features (default-on); the
--no-default-featureslean slice must keep building. - Pure logic in
boatramp-core. Keep routing/config/access decisions pure and unit-testable; push I/O and runtimes to the edges.
Design docs
The docs/*.md files (outside src/) are the design record:
ARCHITECTURE-kv.md— the KV stack and shared-mode coherence.KEYSPACE.md,OPERATING.md— the keyspace and the operator guide.CLOUDFLARE.md— the Cloudflare deployment design.
This documentation site (docs/src/) is built with
mdBook: mdbook serve docs to preview,
mdbook build docs to render.
What’s validated where
Most behavior is unit- and integration-tested natively. Capabilities that need live infrastructure — a real ACME CA, multi-host clusters, the Cloudflare platform — are validated against that infrastructure and flagged as such in context.