Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

What boatramp does

Static hostingContent-addressed blobs, atomic deploys, instant rollback.
Domains & TLSVirtualhosts, ownership verification, automatic certificates.
Auto-DNSTen managed-DNS providers for ACME and custom domains.
FunctionsPortable WASI components — behind a route (handlers), invoked by name (sync/async), or metered & quota’d.
WorkflowsChain functions into a durable DAG with retries, fan-in/out, and compensation.
ComputeContainers and microVMs behind a route, with scale-to-zero.
GatewayLoad-balancing reverse proxy with health checks and retries.
ClusteringRaft-replicated control plane, multi-region reads.
AuthCOSE/CWT tokens, Cedar RBAC, external signers.
Caching & observabilityAutomatic 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

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. 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

5. 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

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.2.7

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.2.7 --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.2.7

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.2.7 -- --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.2.7   # 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.2.7

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.2.7

See also

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

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 default project can never be removed).

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

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

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: [
        ( name: "api", spec: { "spec": { "root": { "image": "ghcr.io/acme/api:1" } }, "replicas": 2 } ),
    ],
)

Each sites[] entry is a slug plus:

  • path — the content directory (defaults to the site build’s output, 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 as project.cfg’s routing.
  • config — the mutable SiteConfig (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. 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 sync flow — 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

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

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

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/default record and an owner/* 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. serve refuses 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-dns feature.

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·admin token:

    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.

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 (and AAAA, 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 port 80 if 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

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

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 --provider names 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)

Reference

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-tenant posture 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 bootstrap is 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

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 / custom there.
  • On a platform that terminates TLS for you (fly.io, Cloudflare), you don’t need this — run --tls off behind 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 configured pop_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 jti replay 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 + ath binding + 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 oidc feature.
  • 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-issuer names the trusted issuer; the server validates each JWT’s iss, aud, and exp against that issuer’s keys.
  • --oidc-audience is 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 whose aud does not match.
  • --oidc-scope-claim names the claim whose values map to boatramp roles — here the scope claim’s values become roles like publisher: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, and Local also take alg: 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-features build 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:

BackendCargo featureserve.signer variant
Local key(built-in)Local(private_key)
AWS KMSsigner-awsAwsKms(key_id, region)
GCP Cloud KMSsigner-gcpGcpKms(key_version, access_token_env)
Azure Key Vaultsigner-azureAzureKv(vault_url, key, key_version, access_token_env)
HashiCorp Vaultsigner-vaultVault(address, key, token_env, alg)
PKCS#11 HSMsigner-pkcs11Pkcs11(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, and whether compute may share the host kernel. 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" )
ProfileFor
multi-tenant (default)untrusted site writers on an untrusted network — strict.
single-tenantone operator who owns every site — relaxed.
devlocal 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.

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 certificate private keys in the control plane. Back the KEK up alongside your other secrets — losing it makes the wrapped certificates unrecoverable (boatramp re-issues them, but any that cannot be re-issued are lost). See Back up & restore.

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)

FieldDefaultMeaning
enabledfalseServe 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/_consoleThe 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 host you 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

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-wasip2 target that exports wasi: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 handlers feature.
  • Site policy that permits handlers and allows every import you request. The requested imports are intersected with the site’s allowed imports at activation; an import the site does not grant is refused — see Use handler bindings.

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. 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.

4. 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

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 typed orm builder instead of writing SQL strings — same databases, same transaction, injection-safe.
  • wasi:blobstore — per-site blob storage over the server’s Storage backend, 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 in routing.consumers subscribes to that topic and processes each message off the request path. Grant wasi:messaging to both the publishing handler and the consuming component, and match the topic name on each side. See Run consumers, crons, and streams.

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.

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). A Lax cookie is withheld on the cross-site POST/fetch an attack would use — the browser half of the defense. Use Lax, not Strict: Strict withholds the cookie when a user arrives from an external link (email, another site), landing them logged-out on first load; Lax still sends it on that top-level navigation.
  • __Host- name prefix (recommended). It forbids a Domain attribute, 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: 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 — so one missed WHERE tenant_id = ? can’t leak across tenants, and Postgres FORCE ROW LEVEL SECURITY becomes a live backstop instead of 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)])?;
}

Placeholders are always ?1, ?2, … — the SQLite-style numbered form — regardless of which engine backs the database. Writing native Postgres $1 (or a :name placeholder) 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> (or sql:*) 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 enforce FORCE 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 a TINYINT (its bool) reads back as the integer 0/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. (Set password_env instead 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 common shapes safely: nested AND/OR/NOT, joins with aliases, aggregates with GROUP BY/HAVING, BETWEEN/IN/LIKE/IS NULL, arithmetic + a portable function set, RETURNING, upserts, and JSON key-path extraction (rendered per engine). Every value is a bound parameter and every identifier is validated, so a query cannot construct an injection; an unbounded UPDATE (no filter, no scope) is refused. Reach for raw sql only for what the builder doesn’t model — subqueries, CTEs, window functions, DISTINCT ON.

#![allow(unused)]
fn main() {
// Same database + transaction as `sql.open("")`; the scope is folded into every query.
let db = orm::open("")?.scoped("tenant_id", tenant);

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()?;
// 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
}

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, expressions).

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.

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:

FeatureWhat it does
Query-guardReject over-deep/complex or introspection queries at the edge.
Persisted queries + safelistSend a query hash instead of the full query; or lock serving to a pre-registered allowlist.
GraphiQL explorerServe the in-browser IDE to a browser GET.
Data connectorServe a GraphQL API generated from a managed database — no resolver code.
SubscriptionsServe a subscription as a graphql-sse event stream off a messaging topic.
FederationCompose several subgraph handlers into one supergraph gateway.
Guest supergraph runsLet a handler run a supergraph operation in-process.
Cookie session authAuthenticate 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_filter is 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.

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) and introspection is 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: 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).

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.

The safelist (managing the allowlist)

Curate the project’s trusted operations with the safelist endpoint. 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 { "hash": "<sha256>" })
curl -X POST https://api.example.com/api/projects/acme/graphql/safelist \
  -H 'content-type: application/json' \
  -d '{"query": "{ me { name } }"}'

curl     https://api.example.com/api/projects/acme/graphql/safelist          # list
curl -X DELETE https://api.example.com/api/projects/acme/graphql/safelist/<hash>  # remove

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:

curl -X PUT --data-binary @accounts.graphql \
  https://api.example.com/api/projects/acme/graphql/subgraphs/accounts
curl -X PUT --data-binary @reviews.graphql \
  https://api.example.com/api/projects/acme/graphql/subgraphs/reviews

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 GET /api/projects/acme/graphql/supergraph.

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": true entry in its boatramp:function-manifest custom 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. Just boatramp deploy (or PUT /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:

    curl -X PUT https://api.example.com/api/projects/acme/graphql/subgraphs/accounts/function
    

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 DELETE /api/projects/acme/graphql/subgraphs/accounts, 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):

curl -X PUT https://api.example.com/api/projects/acme/graphql/subgraphs/accounts/sql \
  -H 'content-type: application/json' \
  -d '{"site": "accounts", "config": {"enabled": true, "tables": {"users": {"columns": ["id", "name"]}}}}'

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, and
  • Query._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 — @key entities, @external/@shareable, root and entity fetches — is supported. The exotic Federation v2 corners (@interfaceObject, progressive @override, deep @requires chains) are not yet planned; a query that needs them will not compose or plan.

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 ),
    ],
),

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

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}.

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.

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) — a wasi:http component built with cargo build --release --target wasm32-wasip2. Needs the wasm32-wasip2 target (rustup target add wasm32-wasip2, or the project’s nix develop shell).
  • --lang js — a JavaScript component built with jco componentize (fetched version-pinned via npx, so only Node is required; nix develop provides it).
  • --lang python — a Python component built with componentize-py (run version-pinned via uvx, so only uv is required; nix develop provides 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_invocations over a window_secs window — 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 the scheduler fires each trigger on the leader, exactly once.

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 bucket QueueConfiguration (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 bucket notificationConfig. Auto-provisioned except the one-time IAM grant giving the GCS service agent roles/pubsub.publisher on the topic (the dry-run recipe 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 the dry-run recipe prints as an az eventgrid command; 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 a 400: 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 (abc).
  • Fan-out — several steps that each depends_on the same upstream step; they become ready together.
  • Fan-in / barrier join — a step that depends_on many 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 (default 1 = no retry). A delivery failure is a 5xx from the engine (a trap, timeout, or a missing component); a response the function itself returns — even a 4xx — counts as a successful delivery.
  • When a step finally fails, the run fails and each already-succeeded step’s compensate function runs in reverse completion order — the saga rollback. In the example, a failed ship triggers refund-card for the completed charge step, which is then marked compensated.

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_hashes allow-list and carry a signature verifying against a static [compute].kernel_signing_pubkeys key. 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:

--isolationRuns onUse for
trusted (default)a container (shared kernel) or a microVMyour own images
untrusteda 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 native container runtime unpacks.
  • --rootfs <hash|file|url> — a rootfs filesystem image (a block device; ext4 by default) the firecracker micro-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).

Next steps

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.

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-header on 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 GET or HEAD,
  • the handler sets Cache-Control: max-age=… (or s-maxage=…),
  • its size is known (Content-Length) and within max_entry_bytes.

And it is never stored when the response is private:

  • Cache-Control: no-store, private, or no-cache,
  • it carries a Set-Cookie,
  • Vary: *, or
  • the request carried an Authorization header and the response did not explicitly opt in with public or s-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 Authorization header (boatramp injects it from the cookie), so it inherits the rule above: a per-user response is not cached unless the handler explicitly marks it public/s-maxage. Never mark a per-user response public — that would let it be stored and served to another user.

Reference

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

StateWhere it livesBack up
Blobs (file contents)<data-dir>/blobs, or your S3/R2 bucketThe 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 onThe KV store’s files/bucket.
Per-node Raft store (cluster)each node’s store_dirEach node separately; it is node-local, never shared.
Secrets KEK (if secrets: local)kek_fileThe 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

  1. Restore the blob store, then the KV store.
  2. Restore the KEK if you use secrets: local, so the control plane can unwrap cert keys.
  3. In a cluster, restore each node’s own Raft store; do not copy one node’s store to another.
  4. Start the server.
  5. 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: local KEK 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-run before 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.

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:

EndpointMeaning
/healthzLiveness — the process is up.
/readyzReadiness — 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

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.

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

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 mcp subcommand a desktop agent spawns. Can drive many named instances from ~/.config/boatramp/mcp.toml.
  • HTTP — a /mcp endpoint served by boatramp serve itself, 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:

FlagMeaning
--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.
--insecureSkip 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 /mcp answers 401 — 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). /mcp rejects 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. /mcp is on by default, but you can turn it off fleet-wide with no restart: boatramp config set mcp.enabled false (it then answers 404); set it back to true to restore. A fast lever if you need to shut the surface off.

Reaching /mcp remotely requires configuring the node’s origin. As an anti-DNS-rebinding defence, /mcp accepts a request only if its Host header 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, set pop_origin to 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-tenant security posture, serve refuses 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 bind 127.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.

FlagDefaultAlternatives
--blobsfss3 (S3 / MinIO / R2 — in the default build)
--kvslatedbmemory, 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

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 sql handler binding — a shared sqld. 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:

  1. fetches the seed’s attestation and verifies it against the root anchor (the same auth pin flow), pinning the seed;
  2. proves possession of its own mesh key (a signature the seed checks — a stolen token alone admits nothing);
  3. 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

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:

  1. Applies the StatefulSet (+ headless Service, per-node PVC, PDB), a client Service, and a ConfigMap.
  2. 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.)
  3. 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.
  4. Keeps a fresh single-use join ticket in the <name>-join Secret (which the pods read as BOATRAMP_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.
  5. 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): the Function CRD is installed and watched, but its apply path awaits the FaaS backend (PLAN-faas); today it reports a Pending status. 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

FieldTypeDefaultDescription
modecluster | statelessclusterRaft StatefulSet, or a stateless Deployment + HPA.
replicasinteger1Desired node count.
imagestringoperator’s own imageContainer image (an explicit version).
storagestringPer-node Raft PVC size (cluster mode).
posturestringSecurity posture floor; a tenant CRD can never relax it.
adminTokenSecretstringSecret (key token) with an admin control-plane token — enables the membership executor.
rootPubkeystringThe cluster root anchor (alg:hex) a joining pod verifies against.
authSecretstringSecret wiring auth into the pods: root-private-key (the founder signs with it) + optional bootstrap-secret.

See also

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.

  1. Import your existing key into the external backend (per that backend’s docs).
  2. 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.
  3. 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

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_ID and CLOUDFLARE_API_TOKEN in 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 current pointer) is a SlateDB store on the same bucket. So a scale-to-zero instance keeps everything across a stop — the in-image /data now 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

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-server is the request-plane library. Its own crate doc puts it plainly: “The server is backend-agnostic: it is handed a [DeployStore] (blobs in any Storage, metadata in any KvStore).” The storage backends live in boatramp-storage, the domain types in boatramp-core — all published on crates.io.
  • boatramp-node is the assembly library. It holds the batteries-included node wiring the boatramp serve binary 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 crates boatramp-server deliberately avoids, so it is the batteries-included assembler you can embed or test in-process.
  • The boatramp binary is a thin shell. What is left in the binary is the environment, not the assembly: parsing project.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 a boatramp_node::assemble call.

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. A router()-only harness sails past all of them.
  • boatramp_node::assemble closes 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-node ships 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 dockerd over the Engine API. assemble registers it whenever a daemon answers, so an in-process harness can drive real docker-backed compute (e.g. Postgres-as-OCI for a handler sql binding) by embedding the serving plane and pointing DOCKER_HOST at a daemon. No boatramp serve subprocess.
  • The container + microVM backends do re-exec a per-workload worker (__sandbox / __vmm-run / __vz-run) — and they re-exec NodeInput::worker_exe (default: this process’s own executable). An embedding harness whose binary doesn’t implement those subcommands sets worker_exe to a built boatramp binary, and then those backends work in-process too: the serving plane stays embedded, and only each workload’s worker re-execs the real boatramp (exactly what boatramp serve does). They still need their substrate — root + cgroup v2 for container, /dev/kvm for the KVM VMM, macOS + Virtualization.framework for vmm-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:

PieceTraitThis example uses
Blob storageboatramp_core::Storageboatramp_storage::FsStorage (a directory)
Control-plane metadataboatramp_core::kv::KvStoreboatramp_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/…, so merge boatramp’s router with your own non-colliding root routes or wrap it in middleware — do not nest it 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; FsStorage or a cloud blob store for blobs. MemoryKv loses everything on restart.
  • Real auth (step 4) for any non-loopback surface.
  • serve_with / router_with to 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 sync against 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’s serve path (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

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 by sync, build, and validate. It covers where and how to publish (including the owning project), an optional build step, and deploy-scoped routing. To declare a whole project — many sites plus its functions and compute — in one manifest, see boatramp apply. See project.cfg.
  • boatramp.cfg — the server config, read by serve. It covers the bind address, storage backends, TLS, request limits, and any cluster section. See boatramp.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 domain and access subcommands.

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 (wasi:keyvalue, sql, wasi:blobstore, wasi:messaging, and invoke — calling another function in-process). 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:

TriggerThe familiar nameWhat fires it
Routehandleran HTTP request matching a host + path
Queueconsumera message on a topic
Timercrona schedule
Invoke(the FaaS verb)a call by function name
Webhooka signature-verified inbound POST
Streamstreamhost-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 / consumers entry. 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, alias a label like prod at a version, rollback independently — 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

Architecture Overview

boatramp is a Rust workspace of feature-gated crates that compose into one binary:

CrateResponsibility
boatramp-coreDomain types, the streaming Storage trait, the pluggable KvStore, content-addressed deploys, routing, config, access/WAF, messaging. No runtime/engine.
boatramp-storageBackends: FsStorage, S3/GCS/Azure blob, SlateDB + Cloudflare KV, libsql + external Postgres/MySQL SQL.
boatramp-serverThe axum HTTP server: serving pipeline, control-plane API, auth, limits.
boatramp-handlersThe wasmtime engine + host bindings for Wasm components.
boatramp-acmeACME (incl. DNS-01) + the DnsProvider abstraction.
boatramp-clusteropenraft integration: RaftKv, RaftMessaging, persistence, membership.
boatramp-firecrackerThe microVM compute backend: an embedded rust-vmm VMM and an external-Firecracker driver, with snapshot/restore.
boatramp-containerThe container compute backend: a jailed worker with namespaces, cgroups, and a seccomp filter.
boatramp-dockerThe remote-Docker compute backend.
boatramp-cloudflareThe Cloudflare Containers compute backend + edge-Worker generator.
boatrampThe 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 Storage backend (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:

  1. Host → site (virtualhost), with an optional default site.
  2. TLS / transport — HTTPS redirect + HSTS (proxy-aware via X-Forwarded-Proto).
  3. Access control — WAF → IP rules → rate limit → basic auth.
  4. Path normalization — clean URLs, trailing-slash policy, dot-segment collapsing (traversal-safe).
  5. Redirects, then handlers, then rewrites / SPA / reverse-proxy.
  6. Resolve to a manifest entry (directory index, custom error documents).
  7. 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

RoleImplementorsWhat it is
Storage (durable)SlateKv (SlateDB over local FS / S3 / R2 / GCS), CloudflareKv, MemoryKvWhere the bytes rest.
Consensus frontendRaftKvTurns writes into replicated Raft entries; serves reads from local applied state. Persists its log + state to a Storage backend per node.
Caching decoratorCachedKvA 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: RaftKv reads 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

  1. Host → site. The Host header 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.
  2. Transport. HTTPS redirect and HSTS, proxy-aware through X-Forwarded-Proto from a trusted proxy.
  3. Access control. WAF, then IP rules, then rate limit, then basic auth — the first to reject wins. See Restrict visitor access.
  4. Path normalization. Clean URLs, the trailing-slash policy, and dot-segment collapsing (traversal-safe).
  5. Route. Redirects, then handlers, then rewrites / SPA fallback / reverse-proxy. A redirect or rewrite may carry a when condition evaluated against the request (language, cookies, headers, file existence), which contributes to the response Vary.
  6. Resolve. Map the path to a manifest entry — a directory index, or a custom error document when nothing matches.
  7. 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:

  1. Cookie session auth. If the site enables cookie_auth and the request carries the named cookie but no Authorization header, boatramp injects Authorization: Bearer <cookie> here, before anything downstream — so the GraphQL edge, the data connector, the handler, and any sibling invoke all see the same bearer. A cookie-authenticated request is CSRF-checked first.
  2. GraphQL edge (if graphql is 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.
  3. Response cache lookup (if cache is on). A cacheable GET/HEAD hit is served without instantiating the handler.
  4. Handler execution. The component runs with its granted host bindings.
  5. 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>.localhost routing are conveniences for local and single-operator use. They are on for a loopback bind, and under the single-tenant and dev security postures; they are off under the default strict multi-tenant posture on a public address, where an unmatched host resolves only to an explicit --default-site or 404. This keeps a public multi-tenant server from ever resolving Host: <sitename>.attacker.example to 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 wantUse
A quick local first runThe single-site default — publish one site, hit /.
Several sites locally, no DNS<site>.localhost (first-label routing).
Production on your own hostnameAttach 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/exp with 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-asserted project.
  • 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.

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 0600 file), or
  • an external signer — AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault Transit, or a PKCS#11 HSM. The Signer trait 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_pubkeys is 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.

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-tenant assumes one operator who owns every site and relaxes the knobs that only matter between mutually-distrusting tenants.
  • dev assumes 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:

ClassWhereHow to change
dynamicKV / control planeboatramp config set … — fleet-wide, no restart
restartboatramp.cfgedit 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 (the docker and cloudflare substrates).
  • tar — a tar rootfs archive, unpacked for the native container runtime.
  • rootfs — a rootfs filesystem image (a block device; ext4 by default), which the firecracker microVM 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

WasmContainermicroVM
Isolationin-process capability sandboxshared kernel + namespacesown kernel (hardware)
Startupsub-millisecondfastboot (or restore)
Runswasi:http componentsany Linux programany Linux program
Trustanycode you trustuntrusted / 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

CapabilityStatus
Static hosting, atomic deploys & rollbackStable.
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 signersStable; KMS/HSM/Vault backends have live seams for the specific service.
Wasm handlers + host bindingsStable.
Caching, compression, observabilityStable.
Single-node deploymentStable.
Clustering (Raft)In-process complete; live multi-host operation is the remaining seam.
Compute — containers & microVMsThe 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 targetNative 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

FlagDescription
--config <path>Config file (project.cfg for client commands, boatramp.cfg for serve).
-h, --helpPrint help for the binary or a subcommand.
-V, --versionPrint the version.

Common client flags

Most client commands accept these, so the per-command tables below list only the flags unique to each command:

FlagEnvDescription
--server <url>BOATRAMP_SERVERServer base URL (overrides publish.server).
--site <name>BOATRAMP_SITETarget site (overrides publish.site).
--project <name>BOATRAMP_PROJECTTarget 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_PUBKEYPin 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

CommandWhat it does
serveRun the HTTP server and publishing API.
projectManage projects — the Workspace that owns sites, functions, and compute.
applyReconcile a whole project (sites + functions + compute) from a declarative apply.cfg manifest.
migrateMigrate 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.
buildRun the configured build command only.
bundleBundle JS/TS + CSS in-process (bundler feature).
composeFuse several Wasm components into one linked handler.
validateParse and check a project.cfg (its routing section).
deploymentsList a site’s deployment history.
rollbackRoll back to the previous (or a specific) deployment.
statusShow a site’s current deployment.
domainAttach/detach hostnames to a site.
aliasManage named pointers to deployments.
accessConfigure visitor access control.
tokenManage control-plane API tokens.
clusterOperate a cluster’s dynamic-join membership.
operatorRun the in-binary Kubernetes operator / print its manifests.
securityInspect the operator security posture.
authGenerate/inspect the root key; edit the RBAC policy.
gatewayPublish a private service through the reverse-proxy gateway.
computeManage microVM compute workloads.
blobUpload a file as a content-addressed blob.
configRead/change the dynamic daemon config (no restart).
mcpRun the Model Context Protocol server (drive boatramp from an AI agent).
dnsConfigure DNS and issue wildcard preview certs (acme-dns feature).
logsTail a site’s captured guest stdout/stderr.
statsShow handler stats, consumer lag, and dead letters.
dlqPurge or redrive a consumer topic’s dead-letter queue.
pruneDelete orphan deployments and unreferenced blobs.
scrubVerify every stored blob still hashes to its key.
cert-statusShow cluster-managed certificate status.
completions <shell>Print a shell-completion script.
manRender the man page to stdout.
cloudflareDeploy 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.

Address, storage, cache

FlagEnvDefaultDescription
--addr <host:port>BOATRAMP_ADDR127.0.0.1:8080Bind address.
--data-dir <path>BOATRAMP_DATA_DIR./dataBlob + KV root for the filesystem backends.
--blobs <fs|s3|gcs|azure>BOATRAMP_BLOBSfsBlob backend (s3/gcs/azure are in the default build).
--kv <slatedb|memory|cloudflare>BOATRAMP_KVslatedbKV backend (cloudflare is in the default build).
--kv-s3BOATRAMP_KV_S3falseRun 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_kvKey prefix for the --kv-s3 store within the bucket.
--s3-bucket <name>BOATRAMP_S3_BUCKETS3/R2 bucket (--blobs s3 and/or --kv-s3).
--s3-endpoint <url>BOATRAMP_S3_ENDPOINTS3 endpoint (MinIO / R2).
--s3-region <region>BOATRAMP_S3_REGIONS3 region (R2: auto).
--s3-path-styleBOATRAMP_S3_PATH_STYLEfalseUse path-style S3 addressing (R2 accepts it).
--gcs-bucket <name>BOATRAMP_GCS_BUCKETGCS bucket (--blobs gcs). Credentials via Application Default Credentials.
--gcs-endpoint <url>BOATRAMP_GCS_ENDPOINTGCS endpoint (a fake-gcs-server emulator).
--gcs-anonymousBOATRAMP_GCS_ANONYMOUSfalseSkip GCS credential resolution (the emulator).
--azure-account <name>BOATRAMP_AZURE_ACCOUNTAzure storage account (--blobs azure).
--azure-container <name>BOATRAMP_AZURE_CONTAINERAzure container (--blobs azure).
--azure-access-key <key>BOATRAMP_AZURE_ACCESS_KEYAzure shared-key auth (prefer the env var).
--azure-emulatorBOATRAMP_AZURE_EMULATORfalseUse the Azurite emulator (well-known dev credentials).
--cache-entries <n>256Front metadata cache size.

Authentication

FlagEnvDescription
--auth-root-private-key <alg:hex>BOATRAMP_AUTH_ROOT_PRIVATE_KEYRoot key: verify and mint tokens.
--auth-root-public-key <alg:hex>BOATRAMP_AUTH_ROOT_PUBLIC_KEYRoot key: verify only.
--bootstrap-secret <secret>BOATRAMP_BOOTSTRAP_SECRETSingle-use secret enabling token bootstrap.
--oidc-issuer <url>BOATRAMP_OIDC_ISSUEREnable OIDC → token exchange for this issuer.
--oidc-audience <aud>BOATRAMP_OIDC_AUDIENCERequired audience claim.
--oidc-scope-claim <name>BOATRAMP_OIDC_SCOPE_CLAIMClaim mapped to boatramp roles.

Warning: with no root key, control-plane auth is disabled. Under the default multi-tenant posture, serve refuses to start that way on a non-loopback --addr. Configure a key, bind 127.0.0.1, or select a looser security posture.

TLS

FlagDefaultDescription
--tls <off|custom|acme|acme-dns|rpk>offTLS 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 productionACME directory URL.
--acme-contact <email>ACME account contact.
--acme-ca-cert <path>Extra CA root (for a private ACME CA).
--acme-cache <path>./data/acmeCertificate cache directory.
--acme-dns-provider <name>manualDNS-01 provider (--tls acme-dns); see DNS providers.
--acme-wildcard-previewfalseAlso issue *.deploy.<domain> for by-id previews.
--http-redirect-addr <host:port>BOATRAMP_HTTP_REDIRECT_ADDRSecond listener that 308s plain HTTP to HTTPS.

Uploads, serving, cluster

FlagEnvDefaultDescription
--max-upload-bytes <n>BOATRAMP_MAX_UPLOAD_BYTESunlimitedReject larger blob uploads.
--upload-idle-timeout-secs <n>BOATRAMP_UPLOAD_IDLE_TIMEOUTAbort an upload idle this long.
--max-concurrent-uploads <n>BOATRAMP_MAX_CONCURRENT_UPLOADSCap simultaneous uploads.
--default-site <name>BOATRAMP_DEFAULT_SITESite served for an unmatched Host (see addressing).
--pop-origin <url>BOATRAMP_POP_ORIGINCanonical origin a per-request proof-of-possession must bind. Required for holder-bound (cnf/PoP) tokens. See PoP-bind a token.
--protect-previewsBOATRAMP_PROTECT_PREVIEWSfalseRequire a token to view /_deploy previews.
--auto-migratefalseMigrate 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-limitBOATRAMP_CLUSTER_RATE_LIMITfalseRate-limit cluster-wide via the KV, not per node.
--shared-cache-coherenceBOATRAMP_SHARED_CACHE_COHERENCEfalseKeep the config cache coherent across processes sharing one KV.
--cluster-initBOATRAMP_CLUSTER_INITfalseFound a new cluster from this node (explicit, one-time). See Deploy a cluster.
--cluster-join <ticket>BOATRAMP_CLUSTER_JOINJoin an existing cluster with a one-paste ticket from cluster add.
--cluster-advertise-addr <url>BOATRAMP_CLUSTER_ADVERTISE_ADDRhttps://<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-actionDescription
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).
lsList all projects.
show <name>Print one project’s full record.
rm <name>Delete a project (refused while it still owns resources, or for the reserved default).

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.

FlagDefaultDescription
-f, --file <path>apply.cfgThe project manifest (RON).
--server <url>Server base URL (overrides [publish].server; env BOATRAMP_SERVER).
--dry-runPrint the plan (what would be built/deployed/activated) and mutate nothing.
--buildRun 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.

FlagDefaultDescription
--data-dir <path>BOATRAMP_DATA_DIRBlob + KV root (the store to migrate).
--kv <slatedb|memory|cloudflare>slatedbKV backend.
--dry-runScan and print the rewrites; write nothing.
--stageCopy-only pass: write the new keys but leave the old ones for a soak/rollback window (the 2-dual state).
--finalizeDelete the old-layout keys left by an earlier --stage, completing the migration.

A plain boatramp migrate (no --stage) copies and finalizes in one shot.

boatramp sync

Build (optional) and publish a folder as a new atomic deployment. Argument: [PATH] — the directory to publish (defaults to build.output, then .).

FlagDescription
--build / --no-buildForce or skip the configured build command.
--no-activateUpload 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.

FlagDescription
--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.

FlagDescription
--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.

FlagDefaultDescription
--limit <n>20Maximum number of deployments to show.

boatramp rollback

Roll back to the previous (or a specific) deployment.

FlagDescription
--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-actionDescription
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.
lsList the site’s hostnames and pending verifications.

domain add flags:

FlagDefaultDescription
--method <http|dns>httpServe 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-waitOnly 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-actionDescription
set <name> <deployment>Point an alias at a deployment id (or unique history prefix).
rm <name>Remove a named alias.
lsList the site’s aliases.

boatramp access

Configure visitor access control. See Restrict visitor access.

Sub-actionDescription
showShow the site’s current access-control policy.
basic-auth add|rm|clearManage HTTP Basic auth credentials. add reads the password from --password or stdin.
ip allow|deny|clearManage IP allow/deny rules (CIDR or bare address); deny wins over allow.
rate-limit set|offSet the per-client requests/second (+ optional burst) or disable it.
trusted-proxy add|clearTrust a reverse proxy by CIDR so its X-Forwarded-For is believed.

boatramp token

Manage control-plane API tokens. See Bootstrap authentication and the RBAC reference.

Sub-actionDescription
create <label>Mint a token (printed once).
bootstrapMint the first token with the single-use BOATRAMP_BOOTSTRAP_SECRET — no admin token needed.
mintMint 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.
lsList issued tokens (short id, label, roles, expiry).
rm <id>Revoke a token by its id or a unique prefix.

create / mint flags:

FlagDescription
--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.
--popMake 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:

FlagEnvDescription
--holder-key <alg:hex>BOATRAMP_HOLDER_KEYHolder private key the parent block’s cnf authorized. Required.
--only-site <site>Restrict to a single site.
--read-onlyRestrict 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-actionDescription
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-keyRotate 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-actionDescription
run [--namespace <ns>]Run the controller: watch the boatramp CRDs and reconcile them.
crdsPrint the CRD YAML (BoatRampCluster / Site / Function).
manifestsPrint the full install bundle: CRDs + least-privilege RBAC + the operator Deployment.

boatramp security

Inspect the operator security posture. See Security posture.

Sub-actionDescription
explainPrint 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-actionDescription
initGenerate 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 getPrint 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-actionDescription
lsList 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-actionDescription
lsList 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).

set takes exactly one root-filesystem source (matched to the substrate); build instead takes --image + --size-mib and produces a --rootfs source:

FlagDefaultDescription
--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>1024ext4 rootfs image size (build only).
--port <n>In-guest TCP port the app listens on. Required.
--vcpus <n>1Virtual CPUs.
--mem-mib <n>256Guest memory (MiB).
--replicas <n>1Desired replica count.
--entrypoint <arg>In-guest entrypoint argv (repeatable).
--env <K=V>Environment variable (repeatable).
--restart <always|…>alwaysRestart policy.
--scale-to-zerofalseSnapshot + stop when idle; restore on the next request.
--isolation <trusted|untrusted>trusteduntrusted 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 blob

Upload a file as a content-addressed blob — the general way to provision an artifact (a microVM kernel, a prebuilt rootfs) that another command references by hash.

Sub-actionDescription
put <file>Upload a file as a blob; prints its hash (the key to pass to compute set --kernel/--rootfs).

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-actionDescription
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.
rollbackRevert to the previous generation.
apply -f <file>Replace the whole dynamic config from a JSON file.
listList the dynamic (runtime-settable) keys.
describe <key>A key’s change class (dynamic vs restart).

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.

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-actionDescription
(none) / serveServe the MCP protocol over stdio until the client disconnects.
setup add <name> --server <url> [flags]Register an instance in ~/.config/boatramp/mcp.toml.
setup listList 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-actionDescription
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 captured guest stdout/stderr. See Observe a running server.

FlagDefaultDescription
--stream <stdout|stderr>bothOnly show one stream.
--limit <n>200Number of recent lines to show.
-f, --followKeep 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-actionDescription
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.

FlagDefaultDescription
--dry-runOnly report what would be removed.
-y, --yesDelete 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>3600Never 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

CommandDescription
completions <shell>Print a shell-completion script (bash, zsh, fish, …).
manRender 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.

FlagDefaultDescription
--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>3Voting nodes — must be 1 on Cloudflare (single durable instance).
--image <ref>boatramp:latestContainer image (pushed to a registry CF can pull).
--domain <host>Public domain the edge Worker serves (repeatable).
--r2-bucket <name>boatramp-blobsR2 bucket for durable blobs + the SlateDB KV.
--d1 <name>boatramp-sqlD1 database for the handler sql binding.
--auth-root-private-key <alg:hex>env BOATRAMP_AUTH_ROOT_PRIVATE_KEYControl-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-runfalsePrint 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:

SectionPurpose
publishWhere and what to publish (sync).
buildAn optional build command run before sync.
bundleThe in-process JS/CSS bundler (bundler feature).
routingRedirects, rewrites, headers, handlers — folded into the deployment.

publish

FieldTypeDescription
serverurlServer base URL. Flag --server, env BOATRAMP_SERVER.
sitestringSite to publish to. Flag --site, env BOATRAMP_SITE.
tokenstringControl-plane token. Prefer BOATRAMP_TOKEN so it is not on disk.
projectstringThe 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, and compute workloads — as one applied unit.

build

Run before sync; its output directory is what gets published.

FieldTypeDescription
commandstringShell command to run (e.g. npm run build).
outputstringDirectory 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.

FieldTypeDefaultDescription
outdirstringdistOutput directory for bundled assets.
jslistJS/TS entry points (tree-shaken, code-split).
csslistCSS entry points (@import inlined).
minifybooltrueMinify 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)

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:

SectionPurpose
serveBind address, data dir, auth keys, upload limits.
securityOperator security posture (profile + per-knob overrides).
secretsEnvelope encryption for cert private keys at rest.
handlersWasm handler runtime (needs the handlers feature).
clusterSelf-hosted Raft cluster (needs the cluster feature).
computeContainer / microVM execution backends.

serve

FieldTypeDefaultDescription
addrsocket address127.0.0.1:8080Bind address. Env BOATRAMP_ADDR.
data_dirpath./dataRoot 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_secretstringSingle-use secret enabling token bootstrap. Prefer the env var / flag so it is not written to disk. Env BOATRAMP_BOOTSTRAP_SECRET.
signersigner enumExternal signer (KMS/HSM/Vault) in place of an in-process key. See below.
max_upload_bytesintegerunlimitedReject blob uploads larger than this.
default_sitestringSite served for a Host matching no domain, instead of 404.
protect_previewsboolfalseRequire a control-plane token to view /_deploy previews.
pop_originstringThe 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_tierdry-run | provision | verify-only | refuseCloud 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_idstringScopes 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).

Warning: with no auth_root_* key configured, control-plane auth is disabled. Under the default multi-tenant posture, serve refuses to start that way on a non-loopback addr. Configure a key, bind 127.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.

VariantFields
Localprivate_key: "<alg>:<hex>"
Vaultaddress, key, token_env, alg (Es256 | Ed25519)
AwsKmskey_id, region (optional)
GcpKmskey_version, access_token_env
AzureKvvault_url, key, key_version, access_token_env
Pkcs11module, 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.

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.

FieldTypeDefaultDescription
profilestringmulti-tenantmulti-tenant (strict), single-tenant (one trusted operator), dev (loopback-loose), or a name from profiles.
overridesknob tableIndividual knobs; a knob is the source of truth, a profile is sugar.
profilesmapCustom named profiles, each a set of overrides over the strict baseline.

Override knobs (byte caps: 0 = unlimited):

KnobDescription
allow_unauthenticated_public_bindPermit a non-loopback bind with auth off.
max_upload_bytesBlob upload cap.
allow_site_unix_upstreamsLet a site’s gateway target unix: sockets.
allow_site_private_upstreamsLet a site’s gateway target private IPs.
max_handler_blob_bytesPer-handler blobstore write cap.
max_component_bytesWasm component size cap.
oidc_require_audienceRequire an aud claim on OIDC exchange.
domain_verify_allow_privateAllow domain-verification probes to private hosts.
domain_verify_self_serveServe 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_computePermit container (shared-kernel) compute; off ⇒ microVM only.
ratelimit_fail_openServe rather than reject if the rate-limit store is unavailable.
allow_implicit_routingResolve 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_popRequire 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.

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.

FieldTypeDescription
envelopestringlocal (machine-local AES-256-GCM KEK) or vault (Vault Transit).
kek_filepathLocal KEK file (auto-generated 0600). In a cluster the same file must be on every node.
vaulttableFor envelope: "vault": addr, key (a Transit key), token_env.

See Encrypt secrets at rest.

handlers

Wasm handler runtime. Parsed always, consumed only with the handlers feature.

FieldTypeDefaultDescription
poolingboolfalseUse the wasmtime pooling allocator (faster instantiation, large virtual-memory reservation).
sync_max_timeout_msint10000Safety-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_msint900000Safety-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_concurrencyint8Max 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_fuelintOptional 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.
outbound_timeout_msintOptional 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.sqltableThe 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.

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 (see compute). 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. With password_env set you bring the credential; omit it and boatramp fully manages the credential — it generates a strong password once, seals it with the secrets envelope, 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).
FieldTypeDefaultDescription
kindstringEngine: postgres (aliases postgresql/pg) or mysql (alias mariadb). Required.
url_envstringBring-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_envstringEnv var holding a read-replica URL. When set, open-read-only routes there; writes stay on url_env.
computestringCompute-backed source. Name of a compute workload (a Postgres/MySQL boatramp runs) to source this database from. Mutually exclusive with url_env.
databasestringCompute-backed: the database name inside the server (non-secret). Required with compute.
userstringCompute-backed: the connecting user (non-secret). Required with compute.
password_envstringCompute-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_maxint8Maximum pooled connections.
read_onlyboolfalseOpen every transaction READ ONLY (the engine rejects writes).
allow_previewboolfalsePermit preview deployments to reach it. Default refuses them, so a preview can’t touch live external data.
connect_timeout_secsint10Connection/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.

FieldTypeDefaultDescription
listensocket addressBind for the Raft peer mesh (distinct from serve.addr).
root_pubkeyslist of stringsserve.auth_root_public_keyThe cluster root anchor set (es256:/ed25519: hex). Every join/trust decision verifies against it. A set enables make-before-break root rotation.
seedslist of stringsControl-plane addresses of existing members. Present ⇒ this node joins; absent + --cluster-init ⇒ it founds.
join_tokenstringThe 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_dirpath<data-dir>/raftThis node’s durable Raft store. Never shared between nodes.
meshtableMesh 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 listen refuses to start with an empty trust set (found with --cluster-init or join with --cluster-join <ticket>). Never point two nodes at one store_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.

FieldTypeDefaultDescription
bridgestringbr-boatrampBridge the guest veths / VM taps attach to.
subnetstring10.0.0.0/24Guest IP subnet.
vcpusintegerdetectvCPUs this node advertises as schedulable (0 = detect).
mem_mibinteger1024Memory (MiB) advertised as schedulable (0 = 1 GiB).
sql_shim_urlurlGuest-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_endpointpublished | bridgepublishedHow 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_modenamed | bindnamedHow 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.
regionstringThis 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_pubkeyslistboatramp’s built-in keyStatic trust anchors ("<alg>:<hex>") for the strict-posture kernel bar; a signed default kernel must verify against one.
kernel_allowed_hasheslistthe released boatramp-vmlinux hashStatic 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.

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 in boatramp.cfg. Editing them needs a process restart; that is deliberate (see The configuration model).
  • dynamic — operational knobs stored in the control-plane KV, changed with boatramp 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

KeyTypeMeaning
default_sitestringCatch-all site for an unmatched Host.
protect_previewsboolRequire a token to view /_deploy previews.
max_upload_bytesintBlob-upload cap (bytes). Clamped by the posture ceiling.
upload_idle_timeout_secsintAbort an upload stalled this long.
max_concurrent_uploadsintCap simultaneous uploads.
cluster_rate_limitboolRate-limit via the shared KV instead of per-node.
compute.vcpusintAdvertised schedulable vCPUs.
compute.mem_mibintAdvertised schedulable memory (MiB).
compute.default_kernelKernelRefFleet default microVM kernel (see below).
console.enabledboolServe the embedded web console.
console.hoststringHost the console answers on (*, an exact host, or *.suffix).
console.pathstringURL path prefix it mounts at (default /_console).
mcp.enabledboolServe the HTTP /mcp endpoint (default on; a live kill-switch — false makes it 404).
posture.oidc_require_audienceboolTighten-only: require an OIDC audience.
posture.ratelimit_fail_openboolTighten-only: set false to fail closed.
posture.allow_shared_kernel_computeboolTighten-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_bytes may only lower the effective cap relative to the boatramp.cfg posture — it can never raise it (and 0 = unlimited is unreachable dynamically unless the static ceiling is also 0). 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

FieldTypeDefaultDescription
versionu321Schema version, pinned at 1.
indexlist<string>["index.html"]Directory-index candidates, tried in order.
clean_urlsboolfalseMap extensionless URLs to .html (/about/about.html).
case_insensitiveboolfalseMatch paths case-insensitively against redirects, rewrites, and files.
trailing_slashenumPreserveTrailing-slash policy — see below.
error_documentsmap<u16, string>{}Status code → error document (404: "/404.html").
redirectslist<Redirect>[]Redirect rules, first match wins.
rewriteslist<Rewrite>[]Internal-rewrite or reverse-proxy rules, first match wins.
headerslist<HeaderRule>[]Response-header rules; every matching rule applies, in order.
cacheCacheConfigDefault Cache-Control — see below.
mime_overridesmap<string, string>{}Extension → MIME override (".webmanifest": "...").
proxy_allowlist<string>[]Allowed upstream hosts for proxy rewrites — see below.
handlerslist<HandlerConfig>[]WebAssembly request handlers, matched after redirects, before static lookup.
consumerslist<ConsumerConfig>[]Message-consumer components, invoked per message on a topic.
cronslist<CronConfig>[]Scheduled handler invocations.
streamslist<StreamConfig>[]Host-level SSE / WebSocket endpoints fanning out topics.

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

ValueEffect
PreserveLeave the path as-is (default).
AlwaysRedirect to add a trailing slash.
NeverRedirect to strip a trailing slash.

redirects

Each rule redirects a matching path. First match wins.

FieldTypeDefaultDescription
frompatternSource path pattern.
tostringDestination, with :name / :splat substitution.
statusu16308HTTP status. 308 is permanent and method-preserving.
whenstringOptional 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.

FieldTypeDefaultDescription
frompatternSource path pattern.
tostringInternal path or absolute proxy URL, with :name / :splat substitution.
statusu16200Status served for an internal rewrite (e.g. 200 for SPA fallback).
whenstringOptional 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:

CallResultNotes
header("name")stringRequest header value ("" if absent). Name must be a literal.
cookie("name")stringCookie value ("" if absent).
query("name")stringQuery-string value ("" if absent).
file_exists("/path")boolDoes that path serve a file in this deployment (honors clean-URLs + index)?
accepts_language("fr")boolDoes Accept-Language accept the tag (primary-subtag match)?
prefers_language(["fr","en"])stringThe 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 matching Vary header (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 no Vary.

headers

Each rule sets or removes response headers on matching paths. All matching rules apply, in order.

FieldTypeDescription
matchespatternPath pattern (named matches because for is a keyword).
setmap<string, string>Headers to set.
unsetlist<string>Header names to remove.
headers: [ (matches: "/assets/*", set: { "Cache-Control": "public, max-age=31536000, immutable" }) ],

cache

FieldTypeDescription
defaultstring?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.

FieldTypeDefaultDescription
routepatternRoute pattern.
methodslist<string>[] (all)HTTP methods answered (GET, POST, …).
componentstringPath to the component .wasm within the deployment.
importslist<string>[]Requested capabilities — see imports.
limitsHandlerLimitsOptional resource caps, intersected with the site caps at activation.
envmap<string, string>{}Static environment variables. Never secrets — a credential-shaped value is rejected at validate; use [handlers].secrets in boatramp.cfg for those.
invoke_targetslist<string>[]Function names this handler may call via the invoke import — see invoke_targets.

imports

The capability vocabulary a handler may request. An unrecognized import is rejected at validate.

ImportGrants
invokeCall sibling functions by name, gated by invoke_targets.
wasi:httpOutbound HTTP.
wasi:keyvaluePer-site KV store.
wasi:blobstorePer-site blob store.
wasi:messagingPublish / subscribe on topics.
sqlThe 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).
wasi:io, wasi:clocks, wasi:random, wasi:loggingStandard 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-*"]) ],

limits (HandlerLimits)

FieldTypeDescription
memory_mbu32?Max linear memory, MiB.
timeout_msu32?Wall-clock timeout, ms.
fuelu64?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.

FieldTypeDescription
topicstringTopic 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.
componentstringPath to the component .wasm.
importslist<string>Requested capabilities.
groupstringConsumer 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.
startlatest | earliestWhere a non-empty group starts on first subscription: latest (default — only new events) or earliest (replay the retained backlog). Ignored for the work-queue.

crons

A scheduled invocation of a declared handler route.

FieldTypeDefaultDescription
schedulestringStandard 5-field cron (minute hour dom month dow).
routestringHandler route to invoke; must be served by a declared handler.
overlapenumSkipSkip 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.

FieldTypeDefaultDescription
routestringRoute the endpoint is served at.
topicslist<string>Topics broadcast to clients (server→client).
websocketboolfalseServe as a WebSocket instead of SSE (adds a client→server direction).
publish_topicstring?For a WebSocket, the topic client→server messages publish to. Omitted = receive-only.

Patterns

Route, redirect, rewrite, and header patterns share one matcher syntax:

TokenMatchesCapture
:nameOne path segment:name in to
* / /*The rest of the path:splat in to
literalItself

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)
ScopeOne deploymentThe whole site
LifecycleImmutable, rolls back with contentMutable, independent
Edited viaproject.cfg + syncboatramp domain / access / gateway / API

Top-level fields

FieldTypeDefaultManaged by
versionu321— (pinned at 1)
domainsDomainConfigemptyboatramp domain
securitySecurityConfigoffAPI / transport security
accessAccessConfigopenboatramp access
handlersHandlersSiteConfig?None (disabled)handler caps
compressionCompressionConfigoffboatramp compression
gatewayGatewayConfig?Noneboatramp gateway

domains

The hostnames a site answers to (virtualhost routing). See Serve a custom domain.

FieldTypeDefaultDescription
primarystring?Canonical hostname (example.com).
aliaseslist<string>[]Additional exact hostnames (www.example.com).
wildcardslist<string>[]Wildcard patterns (*.example.com), matched by suffix at any depth.
canonical_redirectboolfalse301 exact-alias hosts to primary (apex↔www). Wildcard hosts serve as-is.

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.

FieldTypeDefaultDescription
https_redirectboolfalse301 plain-HTTP requests to HTTPS.
hstsHsts?Send Strict-Transport-Security on HTTPS responses.
cspstring?Content-Security-Policy header value (opt-in; no safe default for static sites).
frame_optionsstring?X-Frame-Options value (DENY, SAMEORIGIN).

hsts

FieldTypeDefaultDescription
max_ageu6431536000max-age in seconds (one year).
include_subdomainsbooltrueApply to subdomains.
preloadboolfalseRequest 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.

FieldTypeDefaultDescription
enabledboolfalseWhether handlers run for this site at all.
allow_importslist<string>[]Interfaces handlers on this site may import (subset of the import vocabulary).
max_memory_mbu32?Cap on per-handler memory (MiB).
max_timeout_msu32?Cap on per-handler wall-clock timeout (ms).
max_concurrencyu32?Cap on concurrent invocations for the site.
max_fuelu64?Cap on per-handler CPU fuel; a handler’s own fuel may only lower it.
secretsmap<string, string>{}Env-var name → secret reference (a host env-var name, resolved server-side — never a literal secret).
background_aliaseslist<string>[]Named aliases (besides current) whose deployments also run consumers and crons. See Run background work.
max_stream_connectionsu32?Cap on concurrent SSE/WebSocket connections for the site.
max_log_rateu32?Cap on captured guest log lines per second (over-cap lines are dropped, counted).
disable_log_captureboolfalseOpt 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.
cacheHandlerCacheConfig?None (off)Edge response cache.
graphqlHandlerGraphqlConfig?None (off)GraphQL edge features.
cookie_authCookieAuthConfig?None (off)Browser cookie session auth.

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.

FieldTypeDefaultDescription
enabledboolfalseMaster switch; inert even if present when false.
max_entry_bytesu64?262144 (256 KiB)Largest cacheable entry (status+headers+body); a bigger response streams through uncached.
max_ttl_secsu64?3600Upper 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.

FieldTypeDefaultDescription
enabledboolfalseMaster switch for the GraphQL edge.
max_depthu32?server defaultDeepest allowed selection nesting (fragments expanded).
max_complexityu32?server defaultLargest allowed total field count (schema-free cost proxy).
introspectionbool?posture defaultAllow schema-introspection queries (off under the multi-tenant posture).
persisted_queriesboolfalseResolve a query hash to the stored query (bandwidth + parse saving).
safelistboolfalseOnly pre-registered query hashes run (a query allowlist); implies and is stronger than persisted_queries.
federatedboolfalseThis site is a supergraph gateway: plan a query against the project’s registered subgraphs and dispatch fetches to them.
graphiqlboolfalseServe the in-browser GraphiQL explorer to a browser GET.
dataHandlerGraphqlDataConfig?NoneDeclarative 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.

FieldTypeDefaultDescription
cookie_namestringThe cookie whose value becomes the bearer when no Authorization header is present.
allowed_originslist<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.

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.

FieldTypeDefaultDescription
enabledboolfalseMaster toggle.
min_sizeu641024Don’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.

VariableOverridesDescription
BOATRAMP_SERVERpublish.serverServer base URL.
BOATRAMP_SITEpublish.siteSite to publish to.
BOATRAMP_PROJECTpublish.projectTarget project for site-scoped commands; falls back to [publish].project, then the default project.
BOATRAMP_TOKENpublish.tokenControl-plane token. Prefer the env var so it is never on disk.
BOATRAMP_TOKEN_HOLDER_KEYHolder 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_ORIGINThe server’s canonical origin the PoP proof binds (aud); must equal the server’s serve.pop_origin.
BOATRAMP_MCP_CONFIGPath 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.

VariableDescription
BOATRAMP_ADDRAddress to bind (e.g. 0.0.0.0:8080).
BOATRAMP_DATA_DIRData directory (blobs + embedded KV).
BOATRAMP_DEFAULT_SITESite to serve for an unmatched Host instead of 404.
BOATRAMP_POP_ORIGINCanonical 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_ADDRIn a TLS mode, a second plain-HTTP listener that 308-redirects to HTTPS (e.g. 0.0.0.0:80).
BOATRAMP_PROTECT_PREVIEWSRequire a valid token to view deployment previews.
BOATRAMP_LOG_FORMATjson for structured logs (anything else = human-readable).

Upload limits

VariableDescription
BOATRAMP_MAX_UPLOAD_BYTESReject blob uploads larger than this (default: unlimited).
BOATRAMP_UPLOAD_IDLE_TIMEOUTAbort an upload stalled this many seconds (slowloris guard).
BOATRAMP_MAX_CONCURRENT_UPLOADSCap simultaneous uploads; further uploads get 503 until a slot frees.

Authentication & tokens

See Bootstrap authentication and Authentication & authorization.

VariableDescription
BOATRAMP_AUTH_ROOT_PUBLIC_KEYThe trust anchor. Every node needs it to verify tokens.
BOATRAMP_AUTH_ROOT_PRIVATE_KEYThe signing key. Needed only where tokens are minted; keep it off verify-only nodes.
BOATRAMP_BOOTSTRAP_SECRETSingle-use secret that mints the first admin token, then is retired.
BOATRAMP_HOLDER_KEYHolder 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.

VariableDescription
BOATRAMP_OIDC_ISSUERTrusted issuer URL (its JWKS is fetched for verification).
BOATRAMP_OIDC_AUDIENCERequired audience claim.
BOATRAMP_OIDC_SCOPE_CLAIMClaim carrying the granted roles.

Cluster & shared-store frontends

VariableDescription
BOATRAMP_CLUSTER_RATE_LIMITRate-limit cluster-wide via the shared KV instead of per-node buckets.
BOATRAMP_SHARED_CACHE_COHERENCEKeep local config caches coherent across frontends sharing one KV. See Cache coherence.
BOATRAMP_BLOBSBlob backend (fs, s3, gcs, azure); env form of --blobs.
BOATRAMP_KVMetadata KV backend (slatedb, memory, cloudflare); env form of --kv.
BOATRAMP_KV_S3Run 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_PREFIXKey prefix for the --kv-s3 store within the bucket (default _kv).
BOATRAMP_S3_BUCKETS3/R2 bucket for s3 blobs and (with --kv-s3) the SlateDB KV.
BOATRAMP_S3_ENDPOINTS3-compatible endpoint URL (R2: https://<account>.r2.cloudflarestorage.com).
BOATRAMP_S3_REGIONBucket region (R2 uses auto).
BOATRAMP_S3_PATH_STYLEUse path-style addressing (for non-AWS endpoints; R2 accepts it).
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEYCredentials for the s3/R2 backend (standard AWS resolution).

Handler backends

VariableDescription
BOATRAMP_SQL_TOKENAuth 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_SERIALAttach the microVM serial console (debugging).

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.

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": "..." }. 401 is a missing or invalid token; 403 is 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.

MethodPathPurpose
GET/api/projectsList projects.
POST/api/projectsCreate a project.
GET/api/projects/:projectGet one project’s record.
DELETE/api/projects/:projectDelete 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.

MethodPathPurpose
GET/api/sitesList sites.
POST/api/sites/:site/deploymentsCreate a deployment from a manifest.
GET/api/sites/:site/deploymentsList a site’s deployments.
GET/api/sites/:site/deployments/:idGet one deployment.
POST/api/sites/:site/deployments/:id/activateMake a deployment the live one.
GET/api/sites/:site/currentThe currently active deployment.
GET/PUT/api/sites/:site/configRead / replace the site config.
GET/PUT/DELETE/api/sites/:site/aliases/:nameManage named aliases.
GET/api/sites/:site/aliasesList aliases.

Blobs

MethodPathPurpose
PUT/api/blobs/:hashUpload a content-addressed blob (raw body; the server verifies the hash).

Domains

MethodPathPurpose
GET/POST/DELETE/api/sites/:site/domains/:host/verificationManage a domain-ownership challenge.
POST/api/sites/:site/domains/:host/verification/checkCheck the challenge.
GET/api/sites/:site/domain-verificationsList pending verifications.

Tokens

MethodPathPurpose
POST/GET/api/tokensMint / list tokens.
DELETE/api/tokens/:idRevoke a token by its id.
POST/api/tokens/bootstrapMint the first admin token with the single-use bootstrap secret.
GET/api/auth/whoamiThe presented token’s own roles.
POST/api/auth/exchangeExchange an OIDC JWT for a short-TTL token (oidc feature).

Cluster

MethodPathPurpose
POST/api/cluster/join-tokenMint a single-use bearer mesh join token (admin).
POST/api/cluster/joinAdmit a joining node (gated by the join token in the body + a possession proof, not admin RBAC).
GET/api/cluster/membersList the Raft membership (node, voter, caught-up, leader, address).
POST/api/cluster/promotePromote a caught-up learner to a voter (leader-only).
POST/api/cluster/rotate-keyRotate this node’s mesh key (make-before-break).
POST/api/cluster/revokeRevoke 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.

MethodPathPurpose
GET/api/auth/rootList the extra trusted root anchors.
PUT/api/auth/rootTrust a new root anchor ({ "pubkey": "alg:hex" }).
DELETE/api/auth/root/:pubkeyRetire a root anchor.

See Migrate the root key.

Certificates & cache

MethodPathPurpose
GET/api/certsTLS certificate status.
POST/api/cache/invalidateInvalidate cached responses.

Operations

MethodPathPurpose
GET/POST/api/pruneReport / delete unreferenced deployments.
POST/api/scrubDelete unreferenced blobs.
GET/api/metricsPrometheus exposition (always available).
GET/PUT/api/authz/policyRead / replace the RBAC policy.

Functions & workflows

Top-level (default-project) function and workflow endpoints; the /api/projects/:project/… counterparts scope to another project.

MethodPathPurpose
GET/api/functionsList functions.
GET/PUT/DELETE/api/functions/:nameManage one function (its current version).
POST/api/functions/:name/versionsDeploy a new function version.
POST/api/functions/:name/rollbackRoll back to a prior version.
PUT/DELETE/api/functions/:name/aliases/:labelManage a version alias.
POST/api/functions/:name/invokeInvoke synchronously / async / scheduled.
GET/api/functions/:name/invocations/:idGet an async invocation record.
GET/POST/DELETE/api/functions/:name/triggers[/:id]Manage event triggers (webhook/queue/cron/blob).
GET/api/functions/:name/usageMetering / quota counters.
GET/PUT/DELETE/api/workflows/:nameManage 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.

MethodPathPurpose
GET/api/computeList compute workloads.
GET/PUT/DELETE/api/compute/:nameManage one workload.

Requires KVM on the serving host; the control-plane surface is uniform whether or not execution is available. See Run compute workloads.

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.

MethodPathPurpose
PUT/DELETE/api/graphql/subgraphs/:nameRegister (SDL body) / unregister a subgraph; a publish recomposes and is rejected if it doesn’t compose.
PUT/api/graphql/subgraphs/:name/sqlRegister a SQL-backed subgraph by introspecting a site’s managed database.
PUT/api/graphql/subgraphs/:name/functionRegister a function-backed subgraph by introspecting its _service { sdl }.
GET/api/graphql/supergraphThe composed supergraph (subgraphs, @key entities, root fields).
POST/GET/api/graphql/safelistRegister a trusted operation (returns its hash) / list the safelist.
DELETE/api/graphql/safelist/:hashRemove 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.

Per-site observability

Present with the handlers feature.

MethodPathPurpose
GET/api/sites/:site/_boatramp/handlersPer-handler operator stats.
GET/api/sites/:site/_boatramp/logsCaptured guest logs.
GET/api/sites/:site/_boatramp/logs/streamStream logs (SSE).
POST/api/sites/:site/_boatramp/dlqDead-letter-queue operations.

See Observe a running server.

Agent (MCP)

MethodPathPurpose
POST/GET/DELETE/mcpModel 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.

MethodPathPurpose
GET/healthzLiveness.
GET/readyzReadiness.
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

ActionMeaning
readRead and list (GET endpoints).
writeMutate configuration: site config, aliases, domain verification, cache.
deployShip content: create and activate deployments, upload blobs.
adminFull 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

Two resources are target-scoped: site (target <project>/<site>) and project (target <project>, since 0.2.0). The other five are global.

ResourceScopedGoverns
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.
blobsglobalContent-addressed blob uploads.
tokensglobalAPI token management.
certsglobalTLS certificate status.
cacheglobalCache invalidation.
systemglobalMetrics, 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.

RoleScopedGrants
adminglobaladmin on every resource.
publishersiteread, write, deploy on site (site); deploy on blobs (any).
deployersiteread, deploy on site (site); deploy on blobs (any). No config write.
viewersiteread on site (site).
operatorglobalread on system (any); read on certs (any); write on cache (any). No site access.
project_adminprojectadmin on project (project); admin on site (project/*); deploy on blobs (any). Full control of one project and everything it owns.
project_publisherprojectread, 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_viewerprojectread 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>.

SpecInterpretation
adminGlobal admin.
publisher:acme/blogpublisher bound to site blog in project acme.
viewer:acme/docsviewer bound to site docs in project acme.
project_admin:acmeproject_admin bound to project acme (and every site it owns).
project_viewer:acmeread-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).

MethodPathRequired right
POST/api/auth/exchangenone (carries an IdP JWT)
GET/api/auth/whoaminone (any valid token)
POST/api/tokens/bootstrapnone (bootstrap secret)
POST/api/cluster/joinnone (single-use join token)
PUT/api/blobs/<hash>blobs · deploy
GET/api/sitessystem · read
GET/api/projectssystem · read
POST/api/projectssystem · 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)
POST/api/[projects/<proj>/]sites/<site>/deploymentssite · deploy (target)
GET/api/[projects/<proj>/]sites/<site>/deployments[/<id>]site · read (target)
POST/api/[projects/<proj>/]sites/<site>/deployments/<id>/activatesite · deploy (target)
GET/api/[projects/<proj>/]sites/<site>/configsite · read (target)
PUT/api/[projects/<proj>/]sites/<site>/configsite · 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)
POST/DELETE/api/tokens[/<id>]tokens · admin
GET/api/certscerts · read
POST/api/cache/invalidatecache · write
GET/api/metricssystem · read
GET/POST/api/prune, /api/scrubsystem · admin
any/api/authz/*system · admin
anyother /api/*system · admin (deny-safe)

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

--providerAliasProviderCredential env vars
manualnone (prints records)
cloudflareCloudflareCLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN
route53AWS Route 53ROUTE53_HOSTED_ZONE_ID + the standard AWS chain
ociOracle Cloud DNSOCI_REGION, OCI_ZONE, OCI_KEY_ID, OCI_PRIVATE_KEY_FILE
digitaloceandoDigitalOceanDIGITALOCEAN_DOMAIN, DIGITALOCEAN_TOKEN
hetznerHetzner DNSHETZNER_ZONE_ID, HETZNER_ZONE, HETZNER_DNS_TOKEN
ns1NS1 (IBM)NS1_ZONE, NS1_API_KEY
dnsimpleDNSimpleDNSIMPLE_ACCOUNT_ID, DNSIMPLE_ZONE, DNSIMPLE_TOKEN
gcp-dnsgcpGoogle Cloud DNSGCP_DNS_PROJECT, GCP_DNS_ZONE, GCP_ACCESS_TOKEN
azure-dnsazureAzure DNSAZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, AZURE_DNS_ZONE, AZURE_ACCESS_TOKEN
akamaiAkamai Edge DNSAKAMAI_HOST, AKAMAI_CLIENT_TOKEN, AKAMAI_CLIENT_SECRET, AKAMAI_ACCESS_TOKEN, AKAMAI_ZONE

Notes

  • manual prints the records to apply by hand and reads no credentials. It is the fallback for self-hosted authoritative servers (BIND, PowerDNS, Knot).
  • gcp-dns and azure-dns take a short-lived OAuth2 access token in GCP_ACCESS_TOKEN / AZURE_ACCESS_TOKEN. Mint it with gcloud / az.
  • route53 reads ROUTE53_HOSTED_ZONE_ID for 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-* implies handlers.

FeatureDefaultEnables
fsyesFilesystem blob backend (--blobs fs).
slatedbyesThe default --kv slatedb: a durable transactional LSM over an object_store backend.
s3yesS3 blob backend (--blobs s3) + its S3→SQS blob-change notification provider.
gcsyesGoogle Cloud Storage blob backend (--blobs gcs) + its GCS→Pub/Sub notification provider.
azureyesAzure Blob Storage backend (--blobs azure) + its Event Grid→Storage Queue notification provider.
cloudflare-kvyesCloudflare KV metadata backend.
tlsyesHTTPS: --tls custom (operator cert) and --tls acme (automatic certs).
acme-dnsyesWildcard TLS via ACME DNS-01 plus the dns subcommand (--tls acme-dns) and the pluggable DNS-provider clients. Implies tls.
http3yesHTTP/3 (QUIC) serving alongside the TLS TCP listener. Implies tls.
oidcyesOIDC → token exchange: verify serve against an OIDC issuer’s JWKS.
signer-awsyesExternal token signer backed by AWS KMS.
signer-gcpyesExternal token signer backed by GCP KMS.
signer-azureyesExternal token signer backed by Azure Key Vault.
signer-vaultyesExternal token signer backed by HashiCorp Vault.
signer-pkcs11yesExternal token signer backed by a PKCS#11 HSM.
compressionyesOn-the-fly response compression, opt-in per site.
bundleryesThe in-process JS/TS + CSS bundler for boatramp bundle.
handlersyesThe wasmtime handler engine, component validation at sync, and the sql handler binding (with the typed orm query builder over the same databases).
clusteryesSelf-hosted Raft cluster mode. Implies handlers and slatedb.
sql-postgresyesExternal (bring-your-own) PostgreSQL for the handler sql binding, opened by name. Implies handlers.
sql-mysqlyesExternal (bring-your-own) MySQL/MariaDB for the handler sql binding, opened by name. Implies handlers.
consoleyesBake 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.
mcpyesThe 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:

PlatformCompute backends
Linux x86_64, aarch64microVM (needs /dev/kvm), native container, remote-docker
macOS, Windowsremote-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

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.

MetricTypeLabelsMeaning
boatramp_http_requests_totalcounterstatus_class, cache_resultRequests by status class (2xx / 3xx / …) and cache result.
boatramp_http_response_bytes_totalcounterTotal response body bytes streamed.
boatramp_deployments_totalcounterDeployment manifests created.
boatramp_activations_totalcounterActivations (live / alias pointer flips).
boatramp_cert_renewals_totalcounterACME certificate issues and renewals.
boatramp_daemon_config_infogaugegenerationAlways 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).

FieldMeaning
methodHTTP request method.
pathRequest path.
hostRequest host.
client_ipClient IP address.
statusResponse status code.
bytesResponse body bytes.
encodingContent encoding applied to the response.
cache_resultCache outcome for the request (see below).
duration_msTime taken to serve the request, in milliseconds.

cache_result values

ValueMeaning
fullServed fully from cache.
partialPartial-content (Range) response.
not-modifiedConditional request answered 304.
redirectAnswered with a redirect.
errorAnswered 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 RaftKv in cluster mode) — all control-plane metadata.

Storage (blob content)

KeyValue
<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 reserved default project, so they land under project/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

KeyValue
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/policythe 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)

KeyValue
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)

KeyValue
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

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 prefixValue
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 prefixValue
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).

Cluster Raft store (cluster mode only)

Each node’s durable local KV, distinct from the replicated control plane it serves:

KeyValue
raft/votethe node’s current vote
raft/committed, raft/purgedlog progress markers
raft/log/<index:020>a Raft log entry
raft/sm/last_applied, raft/sm/membershipapplied-state metadata
raft/sm/d/<key>applied state-machine data (mirrors the control-plane keys)
raft/snapshotthe 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:

CodeMeaning
0Success.
1Any 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:

StatusMeaningCommon cause
400Bad requestMalformed body, or an invalid authz policy.
401UnauthenticatedMissing, malformed, expired, or revoked token.
403ForbiddenValid token without the required right.
404Not foundUnknown site, deployment, or alias.
409ConflictState precondition failed (e.g. activating a nonexistent deployment).
413Payload too largeUpload exceeds BOATRAMP_MAX_UPLOAD_BYTES.
429Too many requestsRate limit or upload-concurrency cap reached.
503UnavailableUpload 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 / Messaging trait 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-features lean 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.