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, WebAssembly handlers, and edge compute, with atomic deployments and instant rollback. The same commands and config run on a single node, a self-hosted cluster, or Cloudflare Containers.
Where to start
- New here? Publish something in ten minutes — Publish your first site.
- Running it in production? Start with Deploy a single node.
- Writing handlers? Build and deploy one in Write your first handler.
- Automating or integrating? Read the authentication & authorization model.
What boatramp does
| Static hosting | Content-addressed blobs, atomic deploys, instant rollback. |
| Domains & TLS | Virtualhosts, ownership verification, automatic certificates. |
| Auto-DNS | Ten managed-DNS providers for ACME and custom domains. |
| Handlers | Sandboxed Wasm components with kv / sql / blobstore / messaging bindings. |
| Compute | Containers and microVMs behind a route, with scale-to-zero. |
| Gateway | Load-balancing reverse proxy with health checks and retries. |
| Clustering | Raft-replicated control plane, multi-region reads. |
| Auth | COSE/CWT tokens, Cedar RBAC, external signers. |
| Caching & observability | Automatic caching, compression, metrics, and logs. |
Understand it
The core concepts explain the deployment model, and what boatramp is covers where it fits and what it is not. For per-capability release status, see Maturity, validation & support.
Publish your first site
In this tutorial you run a boatramp server, publish a one-page site, and load it — using only the files you create here. No build tool, no account, no config. By the end you will have published an immutable deployment and served it over HTTP.
You need the boatramp binary on your PATH. If you do not have it yet, see
Install boatramp.
1. Create a site folder
Make a folder with one HTML file:
mkdir my-site
cat > my-site/index.html <<'HTML'
<!doctype html>
<title>Hello from boatramp</title>
<h1>It works.</h1>
HTML
2. Start the server
In one terminal, run the server. With no arguments it serves plain HTTP on
127.0.0.1:8080 and stores data under ./data — enough for this tutorial:
boatramp serve
serving http://127.0.0.1:8080 — data ./data
Leave it running and open a second terminal for the next steps.
3. Publish the folder
Publish my-site as a deployment. sync uploads the files, records a manifest,
and activates the site — all at once:
boatramp sync ./my-site --server http://127.0.0.1:8080 --site my-site
scanned 1 file(s), 1 unique blob(s)
uploading 1 missing blob(s)… done
activated my-site -> 3b1c9f0a
4. Load it
Fetch the site at the server’s root. It is the only site you have published, so
boatramp serves it at / — the same place it will answer once you put it on a
real domain:
curl http://127.0.0.1:8080/
<!doctype html>
<title>Hello from boatramp</title>
<h1>It works.</h1>
You have published and served your first site. Once you publish a second site, you address each one by host — see How a request reaches your site.
5. Change and republish
Edit the page and publish again. Only the changed file uploads, and the site flips to the new deployment atomically:
echo '<h1>Second deploy.</h1>' > my-site/index.html
boatramp sync ./my-site --server http://127.0.0.1:8080 --site my-site
scanned 1 file(s), 1 unique blob(s)
uploading 1 missing blob(s)… done
activated my-site -> 7d42a1e8
curl the site again and you get the new page. The previous deployment still
exists — Publish, roll back, and alias a site shows how
to roll back to it in one command.
Where to go next
- Put it on a real hostname over HTTPS: Attach a custom domain and Get an automatic certificate.
- Run it as a real service: Deploy a single node in production.
- Add dynamic routes: Write your first handler.
Write your first handler
In this tutorial you build a WebAssembly handler, wire it to a route, and call it. You start from a handler boatramp ships as an example, so the build is guaranteed to work, then deploy it to a running server.
You need the boatramp binary (see Install boatramp, and
a server built with the handlers feature) and a Rust toolchain with cargo.
1. Get the example handler
boatramp’s repository ships example handlers under examples/handlers. The
simplest, http-200, exports wasi:http/incoming-handler and answers every
request with a fixed body. Clone the repository and change into it:
git clone https://github.com/BoatRamp/BoatRamp.git
cd BoatRamp
2. Build it to a component
A handler is a WebAssembly component built for the wasm32-wasip2 target. Add
the target once, then build the example in release mode:
rustup target add wasm32-wasip2
cargo build -p boatramp-example-http-200 --target wasm32-wasip2 --release
Finished `release` profile [optimized] target(s) in 21.4s
The component is at
target/wasm32-wasip2/release/boatramp_example_http_200.wasm. Copy it next to a
site folder you will publish:
mkdir -p site
cp target/wasm32-wasip2/release/boatramp_example_http_200.wasm site/hello.wasm
3. Wire it to a route
Create project.cfg in the project folder and declare the handler under
routing.handlers. This entry serves the component at /hello for GET
requests; it requests no host bindings:
(
publish: ( server: "http://127.0.0.1:8080", site: "my-site" ),
routing: (
handlers: [
( route: "/hello", component: "hello.wasm", methods: ["GET"], imports: [] ),
],
),
)
4. 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
- Grant a handler data access: Use kv / sql / blobstore / messaging.
- Run work off the request path: Run consumers, crons, and streams.
- Deploy a component you built elsewhere: Deploy a handler.
Run a three-node cluster locally
In this tutorial you run a real three-node Raft cluster on one machine, publish
to it, read from a different node, and watch it stay up when the leader stops. It
uses loopback addresses and separate data directories, so nothing conflicts. You
need a boatramp binary built with the cluster feature.
For the production version of this, see Deploy a self-hosted cluster.
1. Write three configs
Each node gets its own boatramp.cfg: a distinct node_id, serve port, mesh
listen port, and store_dir. All three list the same peers and voters.
Only node 1 sets bootstrap.
node1.cfg:
(
serve: ( addr: "127.0.0.1:8001", data_dir: "/tmp/br1" ),
cluster: (
node_id: 1,
listen: "127.0.0.1:7001",
bootstrap: true,
voters: [1, 2, 3],
store_dir: "/tmp/br1/raft",
peers: {
"1": (url: "https://127.0.0.1:7001", pubkey: "…node-1…"),
"2": (url: "https://127.0.0.1:7002", pubkey: "…node-2…"),
"3": (url: "https://127.0.0.1:7003", pubkey: "…node-3…"),
},
),
)
node2.cfg and node3.cfg are identical except serve.addr
(127.0.0.1:8002 / :8003), data_dir (/tmp/br2 / /tmp/br3), node_id
(2 / 3), listen (:7002 / :7003), store_dir — and they omit
bootstrap.
2. Collect the mesh public keys
The peer mesh runs over raw-public-key mutual TLS, so each node must know the others’ keys. Start each node once to generate and log its key:
boatramp serve --config node1.cfg
mesh identity ed25519:9f86d081… (/tmp/br1/mesh/identity.key)
cluster listen 127.0.0.1:7001 — waiting for peers [2, 3]
Do the same for node2.cfg and node3.cfg, copy each logged pubkey into the
peers map of all three configs, then stop the nodes.
3. Bring up the cluster
Start node 1 (the bootstrap node) first, then 2 and 3, each in its own terminal:
boatramp serve --config node1.cfg
boatramp serve --config node2.cfg
boatramp serve --config node3.cfg
Confirm membership and the leader:
boatramp status --server https://127.0.0.1:8001
cluster: 3 nodes, leader = 1, term 3
node 1 voter applied 12
node 2 voter applied 12
node 3 voter applied 12
4. Publish to one node, read from another
Writes forward to the leader; every node serves reads from its applied state. Publish to node 1 and read the same content from node 3:
boatramp sync ./site --site my-site --server https://127.0.0.1:8001
curl https://127.0.0.1:8003/
my-site is the only site, so every node serves it at the root. The page served
from node 3 is the deployment you published to node 1 — the write replicated
through Raft.
5. Watch it survive a leader loss
Stop node 1 (Ctrl-C its terminal). The remaining two nodes still have a quorum, so they elect a new leader. Ask a survivor:
boatramp status --server https://127.0.0.1:8002
cluster: 3 nodes, leader = 2, term 4
node 2 voter applied 12
node 3 voter applied 12
node 1 down
Reads and writes continue against the new leader. Restart node 1 and it rejoins and catches up from the log.
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 ships the lean default feature set: publish, serve, handlers, and TLS for most sites. For the platform matrix and what each feature adds, see Cargo features & platform support; to enable extra features, see Build from source.
Every method ends with the same verify step:
boatramp --version
boatramp 0.1.0
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
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.1.0
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
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 and choose which
capabilities to include. The default build is lean — filesystem blobs and the
SlateDB metadata store — and every heavier capability is a cargo feature you add
on the build command.
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
Build the default binary
Build the boatramp package in release mode:
cargo build --release -p boatramp
Finished `release` profile [optimized] target(s) in 2m 41s
This compiles the default features, fs and slatedb. The binary lands at
target/release/boatramp.
Select features
Name extra features with --features, comma-separated, to compile in more
capabilities. This build adds HTTPS, the handler engine, and wildcard ACME
DNS-01:
cargo build --release -p boatramp --features tls,handlers,acme-dns
Finished `release` profile [optimized] target(s) in 3m 12s
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.1.0
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.1.0
See also
- Cargo features & platform support — the full feature list.
- Install boatramp — prebuilt archives, containers, and packages.
Publish, roll back, and alias a site
Every publish is an immutable deployment: boatramp sync uploads a folder’s
blobs, records a manifest, and activates the site to point at it. Activation
is a pointer flip, so switching between deployments is instant. This page covers
publishing, inspecting history, rolling back, and aliases.
Routing config (redirects, headers, SPA fallback) lives in project.cfg; see
Configure routing.
Publish a folder
sync negotiates a manifest with the server, streams only the blobs it is
missing, then activates the result:
boatramp sync ./dist --site my-site --server https://pad.example.com
scanned 128 file(s), 142 unique blob(s)
uploading 12 missing blob(s) (3.4 MiB)… done
activated my-site -> 4f3a2b2c
Re-running sync on an unchanged tree uploads nothing. Change one file and only
that blob uploads before the site flips. 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
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.
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.
Validate before you publish
boatramp validate parses project.cfg and checks the routing rules — glob
patterns, redirect targets, status codes — before anything ships:
boatramp validate
project.cfg: routing OK (2 redirects, 1 rewrite, 1 header rule, clean_urls on)
Migrating from Netlify or Cloudflare Pages? sync folds _redirects and
_headers files into this config, so you keep those rules without rewriting them
— see Migrate from Netlify / Cloudflare Pages.
Publish and verify
Publish the deployment, then confirm the redirect:
boatramp sync ./dist --site my-site
curl -sI https://pad.example.com/old/hello
HTTP/2 301
location: /new/hello
The redirect belongs to this deployment. Roll back — or activate a previous deployment — and the routing rules revert with the content in the same step; there is no separate routing state to reconcile.
Reference
- Full
routingschema and every field: project.cfg schema. - Match order, glob syntax, and precedence: Routing config schema.
Migrate from Netlify / Cloudflare Pages
Move a static site to boatramp without rewriting your redirect and header rules.
On sync, boatramp folds a Netlify-style _redirects file and a _headers file
from the root of your published folder into the deployment’s routing, so those
rules keep working as they are.
Before you start
- A built site directory (for example
./dist). - A boatramp server and a site name. See Publish, roll back, and alias a site.
1. Keep your build output as-is
Build your site with your existing toolchain. Do not change the output. Keep
_redirects and _headers at the root of the folder you publish:
dist/
├── index.html
├── _redirects
└── _headers
A _redirects line such as /old/* /new/:splat 301 and a _headers block carry
over unchanged.
2. Sync the folder
Point sync at the build output:
boatramp sync ./dist --site my-site
folded 4 rule(s) from _redirects, 2 from _headers
uploading 12 missing blob(s)… done
activated my-site -> 4f3a2b2c
The folded rules join the deployment’s immutable routing manifest, so they roll back atomically with the content.
3. Confirm a redirect
Request an old path and check the redirect and its target:
curl -sI https://my-site.example/old/page
HTTP/2 301
location: /new/page
Beyond _redirects and _headers
Those two files cover redirects and header rules. For rewrites, SPA fallback,
reverse-proxy targets, clean URLs, custom error documents, and handlers, write
the routing section of project.cfg. See Configure routing and
the project.cfg schema.
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, so attaching is a two-step task: start
verification, then verify. For every way a request is matched to a site, see
How a request reaches your site.
Before you start
- A published site to attach the host to.
- Control of the host: either the ability to serve a file on it (HTTP), or access to its DNS zone (DNS TXT).
- For the DNS-TXT method, a server built with the
domain-verify-dnsfeature.
1. Start verification
Pick the method that matches the access you have, and run domain add. It
records the host as pending and prints the challenge to publish:
boatramp domain add app.example.com --method http
domain app.example.com — pending (http)
publish token 7f3c9a2e… at:
/.well-known/boatramp-domain-verification/7f3c9a2e…
then run: boatramp domain verify app.example.com
- HTTP token proves you control what the host serves right now. Serve the
printed token under
/.well-known/boatramp-domain-verification/<token>on the host. Works in every build. - DNS TXT proves you control the host’s DNS zone, even while the host still
points somewhere else — the method to use when migrating a live domain. Choose
it with
--method dns:
boatramp domain add app.example.com --method dns
domain app.example.com — pending (dns)
publish TXT record:
_boatramp-verify.app.example.com TXT "7f3c9a2e…"
then run: boatramp domain verify app.example.com
A pending host does not route and cannot request a certificate until it passes.
2. Publish the challenge
Publish exactly what domain add printed:
- HTTP — make the site (or any server on the host) return the token body at
the
/.well-known/boatramp-domain-verification/<token>path. - DNS — add the
_boatramp-verify.<host>TXT record to the zone and wait for it to propagate.
If a managed-DNS provider is configured, skip this step: pass --auto --provider <name> to domain add and boatramp publishes the DNS-TXT record and verifies it
for you. See Automate DNS with a provider.
3. Verify and attach
Run domain verify. It checks the challenge and, on success, attaches the host
to the site so it starts routing:
boatramp domain verify app.example.com
domain app.example.com — verified (http), attached to site my-site
If the check fails, the host stays pending. Confirm the token file resolves, or
that the TXT record has propagated, then run domain verify again.
4. Confirm the attachment
List the site’s domains to see what routes and what is still pending:
boatramp domain ls
app.example.com attached my-site http
beta.example.com pending — dns
Remove a domain
Detach a host — attached or still pending — with domain rm. It stops routing
immediately:
boatramp domain rm app.example.com
domain app.example.com — removed
Next: get a certificate
An attached host is eligible for a certificate but does not have one yet. Issue one so the domain serves over HTTPS — see Get an automatic certificate.
Get an automatic certificate
Issue a certificate for one domain from Let’s Encrypt and serve it over HTTPS.
boatramp requests the certificate on first start, caches it, and renews it before
expiry — no cron, no manual certbot.
For a wildcard certificate, a *.deploy.<host> preview certificate, or a domain
you cannot expose on the public internet, use DNS-01 instead — see
Wildcard certs with DNS-01.
Before you start
- The domain’s
A(andAAAA, if you serve IPv6) record points at the server’s public IP. - The host is attached to a site, so a request for it resolves to content — see Attach a custom domain.
- The ACME challenge reaches the server on port
443(and port80if you bind the redirect listener below).
Issue the certificate
Start serve in acme mode and name the domain:
boatramp serve --tls acme --acme-domain example.com --acme-contact ops@example.com
--acme-domain is repeatable — pass it once per domain to cover several on one
account. --acme-contact registers an email with the ACME account for expiry
warnings; it is optional but recommended.
On first start, boatramp registers the account, solves the challenge, and issues the certificate:
acme: registering account (contact ops@example.com) at Let's Encrypt production
acme: ordering certificate for example.com
acme: certificate issued for example.com — expires 2026-10-07, cached ./data/acme
serving https://0.0.0.0:8080
Verify the live site presents it:
curl -sI https://example.com/
HTTP/2 200
strict-transport-security: max-age=63072000
Redirect HTTP to HTTPS
Bind a second plain-HTTP listener so visitors on http:// are upgraded. In any
TLS mode, --http-redirect-addr answers plain HTTP with a 308 to HTTPS:
boatramp serve --tls acme --acme-domain example.com --http-redirect-addr 0.0.0.0:80
curl -sI http://example.com/
HTTP/1.1 308 Permanent Redirect
location: https://example.com/
Where the certificate is cached
boatramp writes the account key and issued certificate to --acme-cache (default
./data/acme). Restarts reuse the cached certificate instead of ordering a new
one, and renewal rewrites the same directory. Point it at durable storage and
back it up, or Let’s Encrypt rate limits apply the next time an empty cache
re-orders from scratch:
boatramp serve --tls acme --acme-domain example.com --acme-cache /var/lib/boatramp/acme
Reference
- All
serveTLS flags: CLI reference. - Wildcard and preview certs via DNS-01: Wildcard certs with DNS-01.
Wildcard certs with DNS-01
Issue a *.example.com certificate by proving control of the domain through a
DNS TXT record instead of an HTTP path.
Why wildcards need DNS-01
A wildcard name has no single host the CA can reach, so it cannot use the
challenge that --tls acme runs. DNS-01 is the only ACME challenge that
authorizes a wildcard: the CA gives you a token, you publish it as an
_acme-challenge TXT record, and the CA validates the record — not a path on
your server. To publish that record without hand-editing your zone, boatramp
drives a managed DNS provider through its API.
Issue the certificate
Set the provider’s credentials in the environment, then start serve with
--tls acme-dns. This example uses Cloudflare:
export CLOUDFLARE_ZONE_ID=… CLOUDFLARE_API_TOKEN=…
boatramp serve --tls acme-dns \
--acme-domain example.com \
--acme-dns-provider cloudflare
acme-dns: cloudflare provider ready
acme: authorizing example.com, *.example.com via dns-01
acme: published _acme-challenge.example.com TXT, waiting for propagation
acme: certificate issued (expires 2026-10-07)
serving https://0.0.0.0:8080
--acme-domain covers both the apex and its wildcard. Repeat the flag for more
domains.
The ten built-in providers are the same set the DNS automation uses —
cloudflare, route53, oci, digitalocean, hetzner, ns1, dnsimple,
gcp-dns, azure-dns, and akamai — each reading its credentials from
provider-specific environment variables. For the full provider-by-variable table
see DNS providers & credentials; for pointing
custom domains at your server see
Automate DNS with a provider.
Add preview subdomains
To serve by-id preview deployments over HTTPS, add --acme-wildcard-preview. It
issues *.deploy.<domain> alongside the primary wildcard:
boatramp serve --tls acme-dns \
--acme-domain example.com --acme-dns-provider cloudflare \
--acme-wildcard-preview
acme: authorizing *.example.com, *.deploy.example.com via dns-01
acme: certificate issued (expires 2026-10-07)
Publish the TXT record by hand
Without a provider account, use the default manual provider. boatramp prints
the record and waits for you to add it:
boatramp serve --tls acme-dns --acme-domain example.com --acme-dns-provider manual
acme: add this DNS record, then continue:
_acme-challenge.example.com TXT "3P1eF9…kQ"
acme: certificate issued (expires 2026-10-07)
Reference
- All
serveTLS flags: CLI reference. - Provider credentials: DNS providers & credentials.
Automate DNS with a provider
boatramp can drive your managed-DNS provider directly, so pointing a verified custom domain and proving ownership become single commands instead of manual zone edits. This page covers both tasks. For custom-domain concepts, see Attach a custom domain.
Before you start
- A supported managed-DNS provider with its credentials exported in your
environment. The
--providernames and their credential variables are in DNS providers & credentials. - A running server you can reach with
--server.
Credentials are read from the environment only, never from a config file.
Verify ownership automatically
domain add --auto 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 --auto --provider cloudflare
publishing _boatramp-verify.app.example.com TXT via cloudflare
waiting for it to resolve… resolved
verified app.example.com and attached it to my-site
--auto 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 --auto, domain add prints the record to publish by hand and
you 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
- Provider names and credential variables: DNS providers & credentials.
Bootstrap authentication & mint tokens
The control-plane API (publishing, config, tokens) authenticates; public serving never does. This guide takes a fresh server from no auth to a working admin token you can mint scoped tokens with. For the model behind it — COSE/CWT tokens, Cedar RBAC, offline verification — see Authentication & authorization.
1. Generate the root key
boatramp auth init
BOATRAMP_AUTH_ROOT_PRIVATE_KEY=es256:6f2c…
BOATRAMP_AUTH_ROOT_PUBLIC_KEY=es256:03a1…
This is an ES256 (P-256) key pair. The private key belongs to an issuing node — it verifies requests and mints tokens. The public key is the verification trust anchor; a verify-only node sets just that. To keep the private key out of process memory entirely, use an external signer (KMS / HSM / Vault) instead.
2. Start the server with the key
boatramp serve --auth-root-private-key "$BOATRAMP_AUTH_ROOT_PRIVATE_KEY"
control-plane auth enabled (issuer)
Any of --auth-root-private-key, the BOATRAMP_AUTH_ROOT_PRIVATE_KEY
environment variable, or serve.auth_root_private_key in boatramp.cfg enables
auth.
Warning: with no root key configured, auth is disabled — every control-plane request is accepted. Under the default
multi-tenantposture the server refuses to start this way on a non-loopback address. Never run a public, auth-off server.
3. Redeem a single-use bootstrap secret
token create mints through POST /api/tokens, which itself requires an admin
token — a chicken-and-egg on a fresh deploy. Break it with a single-use
bootstrap secret: set it on the server, redeem it once for an admin token,
then remove it. The server mints with its own root key, so nothing sensitive
leaves it, and the token comes back in the response body — never a log.
Set the secret on the server (alongside the root key):
boatramp serve \
--auth-root-private-key "$BOATRAMP_AUTH_ROOT_PRIVATE_KEY" \
--bootstrap-secret "$SECRET"
Redeem it from anywhere that can reach the server — no admin token needed:
BOATRAMP_BOOTSTRAP_SECRET="$SECRET" \
boatramp token bootstrap --role admin --server https://pad.example.com
eyJ… # the admin token — store it now, it is shown once
id: fb156b4f58909058 # metadata id, for `token ls` / `token rm`
The secret is single-use: redeeming it again returns 409. Store the admin
token, then remove the secret from the server. To bootstrap again later (a lost
admin token, key rotation), set a new secret and redeem it.
Note: a key holder can also mint entirely offline with
boatramp token mint, which signs locally through the configured signer (including a KMS/HSM) with no server round-trip. Reserve it for recovery when the server is unreachable;token bootstrapis the normal path, and its tokens are recorded and revocable.
4. Mint scoped tokens
Put the admin token in BOATRAMP_TOKEN, then mint narrower tokens through the
API:
export BOATRAMP_TOKEN=eyJ…
boatramp token create ci-deploy --role publisher:my-site
boatramp token create reader --role viewer:my-site --ttl-secs 86400
eyJ… # the new token — shown once
id: 024619fb948511f5
An admin token can mint any token, including another admin — so rotate a long-lived admin token before it expires instead of re-bootstrapping. Inspect and revoke tokens by their metadata id:
boatramp token ls # id, label, roles, expiry — never the token itself
boatramp token rm <id> # revoke the token and any delegations minted from it
--role is <role> (global) or <role>:<site> (site-scoped). See the full role
and rights model in RBAC roles, actions & resources.
Next steps
- Make a scoped CI deploy token — including offline attenuation.
- Sign in with OIDC.
- Hold the signing key in a KMS/HSM/Vault.
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.
Sign in with OIDC
Enable OIDC on serve so users sign in with an identity provider you already run
(Okta, Keycloak, Auth0, Entra ID), then exchange the provider’s JWT for a
boatramp token. The control plane only ever authorizes boatramp tokens — the IdP
JWT buys you one, and nothing more. For minting tokens without an IdP, see
Bootstrap authentication; for why the exchange works this
way, see Authentication & authorization.
Before you start
- A configured root private key on the issuing node — the exchange mints tokens, so it needs the signer.
- A binary built with the
oidcfeature. - Your IdP’s issuer URL, the audience it stamps for boatramp, and the claim that carries role values.
1. Enable OIDC on serve
Pass the three OIDC flags alongside the root key:
boatramp serve --auth-root-private-key "$KEY" \
--oidc-issuer https://idp.example.com \
--oidc-audience boatramp-api \
--oidc-scope-claim scope
control-plane auth enabled (issuer)
oidc exchange enabled — issuer https://idp.example.com, audience boatramp-api
serving https://0.0.0.0:8080
Each flag has an environment variable — BOATRAMP_OIDC_ISSUER,
BOATRAMP_OIDC_AUDIENCE, BOATRAMP_OIDC_SCOPE_CLAIM — and a boatramp.cfg
entry. On startup the server fetches the issuer’s JWKS and refreshes it
periodically, so a key rollover at the IdP needs no restart.
--oidc-issuernames the trusted issuer; the server validates each JWT’siss,aud, andexpagainst that issuer’s keys.--oidc-audienceis the audience the JWT must carry. Set it: one issuer mints JWTs for many clients, and without an audience check a JWT minted for another client at the same issuer would exchange for a boatramp token. The server rejects any JWT whoseauddoes not match.--oidc-scope-claimnames the claim whose values map to boatramp roles — here thescopeclaim’s values become roles likepublisher:my-site.
2. Exchange a JWT for a boatramp token
Send the IdP JWT as the bearer to /api/auth/exchange on your boatramp server —
not the IdP:
curl -X POST https://pad.example.com/api/auth/exchange \
-H "Authorization: Bearer $OIDC_JWT"
{"token":"eyJhbGciOiJFUzI1NiIs…","roles":["publisher:my-site"],"expires_in":3600}
The server validates the JWT against the issuer’s JWKS, maps the scope-claim
values to roles, mints a short-TTL boatramp token, and returns it. Use that token
as Authorization: Bearer (or BOATRAMP_TOKEN) for every control-plane call. A
rejected JWT — wrong aud, expired, or an unknown signing key — returns 401,
and no token is minted.
Hold the signing key in a KMS/HSM/Vault
Keep the token root signing key outside the boatramp process so it never sits in
process memory. The server resolves the key’s public half at startup — the trust
anchor — and calls the backend to sign each minted token; the private key stays
in the KMS, HSM, or Vault. Configure this under serve.signer in boatramp.cfg.
Verification needs only the public key and stays offline: every node authorizes
requests without contacting the signer. Only minting — token creation, OIDC
exchange, offline token mint — calls the backend, so only the issuing node
needs it. For the wider picture, see
Authentication & authorization.
Before you start
- Provision the root key in your backend as an ES256 (P-256) signing key. The
cloud KMS backends sign ES256 only;
Vault,Pkcs11, andLocalalso takealg: Ed25519. - Build the binary with the backend’s Cargo feature.
- 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)
Build with signer-aws and point serve.signer at the key. AWS credentials come
from the standard provider chain (instance role, AWS_* env vars), not the
config:
cargo build --release -p boatramp --features signer-aws
serve: (
signer: AwsKms(
key_id: "arn:aws:kms:eu-west-1:123456789012:key/abcd-…",
region: "eu-west-1",
),
),
Sign through HashiCorp Vault
Build with signer-vault and target a Vault Transit key. The Vault token comes
from the environment variable named in token_env:
cargo build --release -p boatramp --features signer-vault
serve: (
signer: Vault(
address: "https://vault:8200",
key: "boatramp-root",
token_env: "VAULT_TOKEN",
alg: Es256,
),
),
Start the server. serve.signer supersedes auth_root_private_key:
VAULT_TOKEN="$(vault print token)" boatramp serve --config boatramp.cfg
signer: external Vault(boatramp-root) alg=es256
control-plane auth enabled — verification offline, minting via signer
serving https://0.0.0.0:8080
The six backends
Each maps to a serve.signer variant and one Cargo feature:
| Backend | Cargo feature | serve.signer variant |
|---|---|---|
| Local key | (built-in) | Local(private_key) |
| AWS KMS | signer-aws | AwsKms(key_id, region) |
| GCP Cloud KMS | signer-gcp | GcpKms(key_version, access_token_env) |
| Azure Key Vault | signer-azure | AzureKv(vault_url, key, key_version, access_token_env) |
| HashiCorp Vault | signer-vault | Vault(address, key, token_env, alg) |
| PKCS#11 HSM | signer-pkcs11 | Pkcs11(module, token_label, key_label, pin_env, alg) |
For the full field tables — which fields are optional and the accepted alg
values — see the boatramp.cfg schema.
Restrict visitor access
Control who can reach a site’s public content: password-protect a staging site, allow or deny by IP, and cap request rate. These controls are per-site and apply before any content is read, so a blocked request never stalls a response in flight.
Requests pass the controls in order — WAF → IP rules → rate limit → basic auth — and the first to reject wins. This page covers public-facing access only. To publish a private upstream or tune the SSRF guard, see Load-balance & proxy upstreams; to manage control-plane operators and tokens, see Bootstrap authentication.
All commands take --site (or read it from project.cfg). Show the current
policy:
boatramp access show --site my-site
site my-site
basic-auth 0 users (disabled)
ip no rules
rate-limit disabled
Password-protect a site
Add a basic-auth user. The password is read from --password or, if omitted,
from stdin; it is stored argon2id-hashed, never in plaintext. Visitors without
valid credentials get a 401 challenge:
boatramp access basic-auth add preview --realm "Staging" --site staging
basic-auth: added user 'preview' — site 'staging' now requires authentication
Remove a user, or disable basic auth entirely:
boatramp access basic-auth rm preview --site staging
boatramp access basic-auth disable --site staging
Allow or deny by IP
IP rules take a CIDR or a bare address. Adding an allow rule denies every unlisted client; deny wins over allow:
boatramp access ip allow 203.0.113.0/24 --site my-site
ip: allow 203.0.113.0/24 — unlisted clients denied
boatramp access ip deny 198.51.100.7 --site my-site
Clear all IP rules with boatramp access ip clear. Behind a reverse proxy, the
client address is read from X-Forwarded-For only when the direct peer is a
trusted proxy — register yours:
boatramp access trusted-proxy add 10.0.0.0/8 --site my-site
Apply a rate limit
Set a per-client sustained rate and an optional burst. Over-limit requests get
429:
boatramp access rate-limit set 20 --burst 40 --site my-site
rate-limit: 20 rps, burst 40 (per client IP)
In a multi-process deployment, serve --cluster-rate-limit so the count is
shared through the control-plane KV instead of counted per node. Disable the
limit with boatramp access rate-limit disable.
The WAF
The web-application firewall is the outermost filter in the ordering above. Its
signals are part of the site’s access policy; a request the WAF rejects is
answered 403 before any other check runs.
Choose & inspect a security posture
The security posture is the operator’s trust model, resolved at startup from
boatramp.cfg. It decides defaults for hazards a site writer must not control:
whether a public bind may run without auth, upload and component size caps,
whether a site may reach private-network upstreams, 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" )
| Profile | For |
|---|---|
multi-tenant (default) | untrusted site writers on an untrusted network — strict. |
single-tenant | one operator who owns every site — relaxed. |
dev | local development — loopback-loose. |
A profile is sugar over the individual knobs; the knobs are the source of truth.
Override individual knobs
Layer overrides on the profile to tune one setting without leaving the strict
baseline:
security: (
profile: "multi-tenant",
overrides: (
max_upload_bytes: 104857600, // 100 MiB (0 = unlimited)
allow_site_private_upstreams: true, // let sites' gateways reach private IPs
),
)
The full knob list is in the boatramp.cfg schema.
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.
Deploy a handler
Serve a route from an already-built WebAssembly component. You declare the
handler in project.cfg, validate the manifest, then sync — 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.
Before you start
- A component built to the
wasm32-wasip2target that exportswasi:http/incoming-handler. Sync rejects a component without this export. - The component file reachable from your project root (here,
dist/api.wasm). - A server built with the
handlersfeature. - 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, 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.
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
- Route and import fields: project.cfg schema.
- Using bindings from guest code: Use handler bindings.
- Build a handler end to end: Write your first handler.
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.wasi:blobstore— per-site blob storage over the server’sStoragebackend, key-prefixed per site. Use it for uploaded files and generated artifacts too large for the key/value store.wasi:messaging— publish/subscribe and queues. A handler publishes to a topic; a consumer declared inrouting.consumerssubscribes to that topic and processes each message off the request path. Grantwasi:messagingto both the publishing handler and the consuming component, and match the topic name on each side. See Run consumers, crons, and streams.
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.
Tail guest output with boatramp logs if a binding call traps — see
Observe a running server.
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 ),
],
),
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.
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).
Deploy a container image
compute build takes an OCI image reference, builds an ext4 root filesystem from
it, uploads it, and registers the workload in one step. It needs the mke2fs
tool (e2fsprogs) on the host and a kernel blob provisioned once.
boatramp compute build web \
--image nginx:1.27 \
--kernel <vmlinux-blob-hash> \
--port 80 \
--vcpus 1 --mem-mib 256 --replicas 2
built ext4 rootfs from nginx:1.27 (1024 MiB) — blob sha256:1a2b…
workload web set: 2 replicas, port 80, isolation trusted
The scheduler places the replicas on nodes that advertise compute capacity and reconciles them toward the desired count. Check status:
boatramp compute ls
NAME REPLICAS PORT ISOLATION STATE
web 2/2 80 trusted Healthy
Choose the isolation level
--isolation decides which backend may run the workload:
--isolation | Runs on | Use for |
|---|---|---|
trusted (default) | a container (shared kernel) or a microVM | your own images |
untrusted | a microVM only (never a shared kernel) | third-party or tenant code |
boatramp compute build tenant-app --image ghcr.io/acme/app:1.4 \
--kernel <vmlinux-blob-hash> --port 8080 --isolation untrusted
Under the strict multi-tenant security posture, shared-kernel (container)
compute is disabled, so every workload runs in a microVM regardless of
--isolation. See Choose a security posture.
Set a workload from existing blobs
If you already pushed a rootfs and kernel, register the workload directly with
compute set (same flags as build, minus the image build):
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
Next steps
- Scale compute to zero when a workload is idle.
- Load-balance & proxy upstreams to route traffic to it.
Scale compute to zero
A scale-to-zero workload snapshots and stops when it goes idle, then restores on the next request. You pay no CPU or memory for an idle service, and a cold request pays a restore instead of a full boot. It applies to microVM workloads, whose device-model state (including in-flight queue cursors) can be snapshotted and resumed.
Enable it per workload with --scale-to-zero:
boatramp compute build web \
--image nginx:1.27 --kernel <vmlinux-blob-hash> \
--port 80 --scale-to-zero
workload web set: 1 replica, port 80, scale-to-zero on
The workload runs normally under load. When it is idle, its state is snapshotted and the microVM stops; the next request restores it from the snapshot. A restore is faster than a boot because the guest resumes where it left off rather than re-initializing.
Note: the snapshot/restore mechanism is validated live (a serve → snapshot → restore → serve round-trip). The automatic idle→snapshot and request→restore reconcile is being finished; treat scale-to-zero as production-ready for the mechanism and pre-1.0 for the fully automatic idle detection. See Maturity, validation & support.
For the mechanism itself and when to choose scale-to-zero over always-on, see Compute: handlers vs containers vs microVMs.
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).
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
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..."
Reference
- Full
routingschema, includingcache.defaultand header-rule fields: project.cfg reference. - Content negotiation and
Content-Encoding: Enable compression.
Enable compression
boatramp negotiates compression per request from the client’s Accept-Encoding.
Precompressed sibling variants are preferred over on-the-fly compression because
they cost no per-request CPU. This page covers both. For how compression
interacts with Cache-Control and ETag, see Control caching.
Ship precompressed variants
At sync, boatramp compresses compressible files and stores br and gzip
blobs next to the identity blob — an app.js gets app.js.br and app.js.gz
siblings. A variant is kept only when it is smaller than identity.
At serve time boatramp negotiates Accept-Encoding (brotli over gzip, honoring
;q=0 and *), returns the best variant the client accepts, and sets
Content-Encoding, a per-representation ETag, and Vary: Accept-Encoding.
Request the brotli variant:
curl -sI -H 'Accept-Encoding: br' https://my-site.example/app.js
HTTP/2 200
content-type: text/javascript
content-encoding: br
vary: accept-encoding
A client sending no Accept-Encoding — or identity — gets the uncompressed
blob and the same Vary header.
Compress on the fly
Responses with no precompressed variant — dynamic handler and proxy output —
can be compressed per request. Build with the compression feature and enable
it in the site’s config:
// site access/compression config
compression: ( enabled: true, min_size: 1024 ),
boatramp streams a gzip or brotli encoder over compressible responses at least
min_size bytes. It skips Set-Cookie responses for BREACH safety, and Range
requests always serve identity. Where a precompressed variant exists it still
wins — on-the-fly compression only fills the gap.
Back up & restore
boatramp keeps its state in a few well-defined places. Back up each one, and a restore is putting them back and re-verifying. There is no single dump command — you snapshot the backends you configured.
What to back up
| State | Where it lives | Back up |
|---|---|---|
| Blobs (file contents) | <data-dir>/blobs, or your S3/R2 bucket | The directory, or the bucket (versioning/replication). |
| Control-plane metadata (deployments, site config, tokens, cert records) | the KV: <data-dir>/kv-slate, or the object store SlateDB runs on | The KV store’s files/bucket. |
| Per-node Raft store (cluster) | each node’s store_dir | Each node separately; it is node-local, never shared. |
Secrets KEK (if secrets: local) | kek_file | The KEK. Without it, wrapped certificates are unrecoverable. |
| ACME certificate cache | --acme-cache (default <data-dir>/acme) | Optional — certificates re-issue, but backing it up avoids re-issuance and rate limits. |
Blobs are content-addressed and metadata references them by hash, so the two must be backed up as a consistent pair — back up the KV no earlier than the blobs so every referenced blob exists.
Restore
- Restore the blob store, then the KV store.
- Restore the KEK if you use
secrets: local, so the control plane can unwrap cert keys. - In a cluster, restore each node’s own Raft store; do not copy one node’s store to another.
- Start the server.
- Verify blob integrity:
boatramp scrub
scrub: 512 blobs verified, 0 corrupt, 0 missing
scrub re-hashes every stored blob and confirms it still matches its key, so a
partial or corrupt restore is caught before it serves bad content. If it reports
missing blobs, the KV was restored ahead of the blob store — restore the blobs
and re-run.
Warning: losing the
secrets: localKEK makes envelope-wrapped certificate keys unrecoverable. Back the KEK up with your other secrets, separately from the data it protects. See Encrypt secrets at rest.
Garbage-collect & verify integrity
boatramp prune reclaims disk by deleting orphaned deployments and the blobs no
deployment references. boatramp scrub re-hashes every stored blob to confirm
its content still matches its key. Run prune to recover space; run scrub to catch
bit-rot, tampering, or unreadable blobs — for example after restoring a
backup.
Warning: prune deletes data. Deleted deployments and blobs are gone. Keep enough deployment history to roll back to, and preview with
--dry-runbefore you delete anything.
1. Preview what prune would delete
Run a read-only pass first. Nothing is deleted:
boatramp prune --dry-run
scanning 3 site(s), 4213 blob(s)…
my-site 12 deployment(s), keep 10, prune 2
other-site 5 deployment(s), keep 5, prune 0
would delete 2 orphaned deployment(s), 87 unreferenced blob(s) — 214 MiB
dry run: nothing deleted
2. Prune
Prune previews, asks for confirmation, then deletes. A grace window
(--grace, default 3600s) protects a just-uploaded, not-yet-activated deployment
from being collected mid-publish. Aliased deployments are retention-protected.
boatramp prune --keep-last 10 --keep-age 604800
prune 2 orphaned deployment(s), 87 unreferenced blob(s) — 214 MiB. proceed? [y/N] y
deleted 2 deployment(s), 87 blob(s) — reclaimed 214 MiB
--keep-last N— keep the N most recent deployments per site.--keep-age SECONDS— also keep anything activated within that age.--yes— skip the confirmation prompt (for cron).
Prune also reclaims orphaned content-addressed site-config bodies once no site points at them.
3. Scrub
boatramp scrub re-hashes every stored blob and reports any whose content no
longer matches its key, or that cannot be read. It is read-only:
boatramp scrub
4213 blob(s) verified, all intact
Scrub exits non-zero on any finding, so it fits a cron or health check. A failure names the offending key:
blob 9f86d081… corrupt: content hash mismatch
1 of 4213 blob(s) failed verification
Verification is offline by design: the serving path cannot re-hash a blob without buffering it whole, which would break streaming. Run scrub after restoring a backup to confirm every restored blob is intact before you serve traffic.
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","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 cache_result field is one of full, partial, not-modified,
redirect, or error. Verbosity follows RUST_LOG (default boatramp=info).
Pipe the sink to your log shipper, or to jq to read one field:
BOATRAMP_LOG_FORMAT=json boatramp serve | jq -r 'select(.target=="boatramp::access") | .status'
200
304
200
Check health
Two endpoints report health. Point a load balancer or orchestrator probe at them:
| Endpoint | Meaning |
|---|---|
/healthz | Liveness — the process is up. |
/readyz | Readiness — a cheap KV probe; returns 503 when the metadata backend is unreachable. |
Probe readiness — a 503 means the process is up but the metadata backend is
unreachable, so route no traffic to this node yet:
curl -i http://localhost:8080/readyz
HTTP/1.1 200 OK
ready
Scrape metrics
An admin-scoped Prometheus exporter is always served at /api/metrics, carrying
the process-wide serving and lifecycle counters. With the handlers feature it
also renders per-handler invocation counters and per-consumer queue-depth and
dead-letter gauges. Scrape it:
curl http://localhost:8080/api/metrics
# HELP boatramp_http_requests_total requests by status class and cache result
# TYPE boatramp_http_requests_total counter
boatramp_http_requests_total{status_class="2xx",cache_result="full"} 1420
boatramp_http_requests_total{status_class="3xx",cache_result="not-modified"} 87
boatramp_deployments_total 12
boatramp_activations_total 9
For every metric, its labels, and their meaning, see the metrics reference.
Tail guest logs and read handler stats
For sites running handlers, two commands report per-site activity. Tail the
captured guest stdout and stderr, 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.
Reference
- Full metric and access-log-field tables: Metrics reference.
Manage certificates in a cluster
In a cluster the leader owns TLS. It issues each certificate once, stores it in the replicated control plane, and every node serves that replicated cert and hot-swaps it on renewal. You configure ACME on the cluster, not on each node.
For single-node issuance, see Get an automatic certificate. To stand a cluster up first, see Deploy a self-hosted cluster.
How cluster certs work
- One writer. The leader runs the ACME account and drives the DNS-01 / HTTP challenge, so competing nodes never race to answer the same challenge or double-register an account.
- Replicated storage. An issued certificate commits to the Raft log like any other control-plane write. Every voter and learner applies it and holds the same cert.
- Local serving. Each node serves TLS from its own applied copy. A node that joins later replicates the existing certs before it accepts traffic.
- Hot-swap on renewal. When the leader renews, the new cert replicates and each node swaps it in on the next handshake. Live connections stay up and you restart nothing.
Set the ACME options in boatramp.cfg once and apply the same config to every
node. Do not point individual nodes at their own file-cache certs.
List managed certificates
boatramp cert-status reads the replicated store and prints each managed
certificate with its domain and days to expiry. It never prints key material:
boatramp cert-status --server https://10.0.0.1:8080
example.com (74d left)
www.example.com (74d left)
api.example.com (12d left)
The --server flag (or the BOATRAMP_SERVER environment variable) points at any
node; every node returns the same replicated list. A certificate past its expiry
shows (EXPIRED) instead of a day count. When the control plane holds no managed
certificates, the command prints no cluster-managed certificates — you also see
this on a single node using a local file cache (--tls acme), which is not
cluster-managed.
Renewal
Renewal is automatic. The leader tracks each certificate’s expiry, renews ahead
of time, and replicates the result. Run cert-status to watch the day count
reset after a renewal; you do not renew by hand and you do not restart nodes.
If the day count stops falling near expiry, check that the leader reaches the ACME provider and that the challenge still resolves — the same credentials you set for ACME issuance.
Deploy a single node in production
One process, local disk, authenticated control plane, TLS. Blobs go to the filesystem; control-plane metadata goes to an embedded SlateDB that is durable on every write. This is the whole platform on one host.
For when to move beyond one node, see Deployment topologies.
1. Generate a root key and set up auth
The control plane authenticates every management request. Generate a root key once:
boatramp auth init
BOATRAMP_AUTH_ROOT_PRIVATE_KEY=es256:6f2c…
BOATRAMP_AUTH_ROOT_PUBLIC_KEY=es256:03a1…
Keep the private key in the server’s environment (or a secrets manager). Full flow — including minting your first admin token — is in Bootstrap authentication.
Warning: under the default
multi-tenantsecurity posture,serverefuses to start on a non-loopback address with no root key. That is deliberate: a public bind with auth off exposes the control plane. Configure a key (below), or bind127.0.0.1, or select a looser posture for local use — see Choose a security posture.
2. Run the server
boatramp serve \
--addr 0.0.0.0:8080 \
--data-dir /var/lib/boatramp \
--auth-root-private-key "$BOATRAMP_AUTH_ROOT_PRIVATE_KEY"
control-plane auth enabled (issuer)
serving http://0.0.0.0:8080 — data /var/lib/boatramp
Blobs land under <data-dir>/blobs and the KV under <data-dir>/kv-slate. A
write-through in-memory cache fronts hot metadata, so an activate is visible
immediately.
Prefer a config file for anything non-trivial: put the same settings in
boatramp.cfg and run boatramp serve --config boatramp.cfg. Flags and
environment variables override the file. See the
boatramp.cfg schema.
3. Add TLS
Terminate TLS at boatramp with an automatic certificate:
boatramp serve --config boatramp.cfg \
--tls acme --acme-domain pad.example.com \
--http-redirect-addr 0.0.0.0:80
--http-redirect-addr opens a second listener that answers plain HTTP with a
308 to HTTPS. For wildcard certificates, custom certificates, and the DNS-01
flow, see Get an automatic certificate.
To terminate TLS at a reverse proxy instead, run --tls off, set the site’s
https_redirect, and list the proxy in the site’s trusted_proxies so
X-Forwarded-For and X-Forwarded-Proto are believed.
4. Choose the storage backends
--blobs and --kv select where data rests. The defaults (fs, slatedb)
suit a single node.
| Flag | Default | Alternatives |
|---|---|---|
--blobs | fs | s3 (S3 / MinIO / R2 — needs --features s3) |
--kv | slatedb | memory, cloudflare (needs --features cloudflare-kv) |
SlateDB runs over any object store, so a single node can keep its KV on S3/R2 as well. Full option list: boatramp.cfg schema.
Next steps
- Bootstrap authentication & mint tokens
- How a request reaches your site
- Attach a custom domain
- Back up & restore
- Observe: logs, metrics, health, stats
- Scale out: Deploy a self-hosted cluster
Deploy a self-hosted cluster
A cluster replicates the control plane with Raft. Writes go to the leader and
commit to a replicated log; every node serves reads from its local applied
state. It is the same binary and the same commands as a single node — clustering
is a cluster: section in boatramp.cfg, not a separate mode.
Use a cluster when you need highly available control-plane writes, or low-latency reads in more than one region. For the topology and its trade-offs, see Deployment topologies.
Before you start
- An odd number of voters (3 or 5) in one low-latency region for the quorum. Add learners in other regions for local reads; a learner replicates the log and serves reads, forwards writes to the leader, and never votes.
- A shared blob backend (S3 / R2) so every node serves the same content, and — if
you use the
sqlhandler binding — a sharedsqld. Each node keeps its own Raft store on local disk. - One node designated to bootstrap the cluster.
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. Write each node’s config
Config is RON, in boatramp.cfg. The peer mesh runs over RFC 7250 raw-public-key
mutual TLS: each node generates an Ed25519 mesh identity on first start and
logs its public key. You put every node’s public key in the peers map — that
map is the genesis trust set. A non-loopback listen refuses to start without
it.
node-1, the bootstrap node:
(
serve: (
addr: "0.0.0.0:8080",
blobs: "s3",
kv: "slatedb",
auth_root_private_key: "es256:…",
),
cluster: (
node_id: 1,
listen: "0.0.0.0:7000", // the Raft peer mesh, distinct from serve.addr
bootstrap: true, // set on exactly ONE node, at first bring-up
voters: [1, 2, 3],
store_dir: "/var/lib/boatramp/raft",
peers: {
"1": (url: "https://10.0.0.1:7000", pubkey: "…node-1 hex…"),
"2": (url: "https://10.0.0.2:7000", pubkey: "…node-2 hex…"),
"3": (url: "https://10.0.0.3:7000", pubkey: "…node-3 hex…"),
},
),
)
node-2 and node-3 are identical except for node_id, and they omit
bootstrap. See the full schema in the
boatramp.cfg reference.
2. Collect the mesh public keys
Start each node once. It generates its identity and logs the key:
boatramp serve --config boatramp.cfg
mesh identity ed25519:9f86d081… (/var/lib/boatramp/mesh/identity.key)
cluster listen 0.0.0.0:7000 — waiting for peers [2, 3]
Copy each node’s pubkey into every node’s peers map, then restart. Until the
trust set is complete, nodes reject each other’s mesh connections.
3. Bring up the cluster
Start the bootstrap node first, then the others:
boatramp serve --config boatramp.cfg
The bootstrap node forms a single-voter cluster; the others join as voters per
voters. Confirm membership and the leader:
boatramp status --server https://10.0.0.1:8080
cluster: 3 nodes, leader = 1, term 4
node 1 voter applied 128
node 2 voter applied 128
node 3 voter applied 128
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
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.
Membership changes
Add a learner by giving it a config with its own node_id, starting it, and
adding it through the membership API; it replicates the log and serves local
reads without joining the quorum. Promote it to a voter, or remove a node, with
the same API.
Reference
- Full
cluster:schema: boatramp.cfg schema. - Per-node Raft keys: KV keyspace.
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.
Before you start
wranglerinstalled and authenticated against your Cloudflare account.- A boatramp build with
--features cluster. The generatedDockerfilebuilds the binary with this feature, so a Docker builder is enough. - An R2 bucket (blobs) and a D1 database (the
sqlhandler binding), created ahead of time, with their credentials set as wrangler secrets.
1. Build the container image
Build the image the Containers run, and push it to a registry Cloudflare can pull from:
docker build -t registry.example.com/boatramp:v1 .
docker push registry.example.com/boatramp:v1
v1: digest: sha256:… size: 1573
2. Generate the deployment
Run boatramp cloudflare to plan the topology and write the deployment artifacts
— per-node cluster configs, a Dockerfile, a wrangler.jsonc, and the edge
Worker crate:
boatramp cloudflare \
--region wnam --region weur --region apac \
--primary wnam --quorum 3 \
--image registry.example.com/boatramp:v1 \
--domain example.com --r2-bucket boatramp-blobs --d1 boatramp-sql \
--out ./cloudflare
Generated 5 node(s) (3 voters in wnam, 2 learner(s)) → ./cloudflare
Review the artifacts, then `wrangler deploy` (or re-run with --apply).
--primary hosts the voting quorum; the other regions host read-only learners
that serve local reads and forward writes to the leader. Keep --quorum odd.
3. Deploy
Review the artifacts, then push them with wrangler:
cd ./cloudflare && wrangler deploy
Published boatramp
https://example.com/*
To generate and deploy in one step, re-run step 2 with --apply (it runs
wrangler deploy for you and needs your Cloudflare credentials).
4. Publish and verify
Point publishing at the deployed domain — it behaves the same as any boatramp server, because content is backend-durable:
boatramp sync ./dist --site my-site --server https://example.com
curl https://example.com/healthz
ok
Reference
- Full
cluster:schema: boatramp.cfg schema. - The edge/origin split and its trade-offs: Deployment topologies.
What is boatramp?
boatramp is software you run to publish static sites, WebAssembly handlers, and private services on your own infrastructure. 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 handlers, and authorization as one system.
Who it is for
Developers who want atomic deploys and instant rollback without a vendor, and operators who want one binary, one config format, and the same commands whether they run a single node, a Raft cluster, or Cloudflare Containers.
Where to go next
- Evaluating boatramp? Publish something in your first site.
- Running it in production? Start with deploying a single node.
- Writing dynamic routes? See the handlers tutorial.
- Want the model behind it? Read the core concepts.
Core concepts
boatramp is built on a small set of ideas. Understand these and the rest of the docs follow. This page explains the deployment model and the three configuration tiers; for exact fields, see the reference pages linked below.
Content is content-addressed
Every file boatramp serves is a blob — the raw bytes of one file, stored once and keyed by the SHA-256 of its contents. Because the key is the hash, identical bytes share a key across files, across sites, and across time. Two deployments that share an unchanged asset point at the same blob; no copy is made.
A deployment is an immutable manifest: a map from each site path to the
hash of the blob that answers it. The manifest names content by hash rather than
storing it, so a deployment is small, and once written it never changes. Routing
config authored in project.cfg is folded into the manifest, so it is versioned
and rolls back with the content it describes.
Publishing uploads only what is missing
When you publish, the client computes the manifest and asks the server which blobs it already holds. Only the missing blobs stream up; everything the server has seen before — from this site or any other — is skipped. A rebuild that touches one file uploads one blob.
Once the blobs are present, the server stores the new manifest and activates it by flipping the site’s current pointer in a single atomic step. A reader sees the previous deployment or the new one in full, never a half-written mix. Because every past manifest still exists and its blobs are still addressable, rollback is instant: activation points the site at an older manifest, with nothing to re-upload.
Aliases are named pointers
A site’s current pointer is one such reference; an alias is another. An alias
is a named pointer — staging, a per-branch preview — that resolves to a
specific deployment independently of the live pointer. You publish to an alias to
review a build, then activate it for the site when it is ready. Promotion is a
pointer move, not a rebuild.
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-project client config, authored beside your code and read bysync,build, andvalidate. It covers where and how to publish, an optional build step, and deploy-scopedrouting. Seeproject.cfg.boatramp.cfg— the server config, read byserve. It covers the bind address, storage backends, TLS, request limits, and anyclustersection. Seeboatramp.cfg.- Per-site config — domains, transport security, access control,
compression, and handler policy. This lives in the control-plane store, not
a file, so it travels with the server and is edited through the API and the
domainandaccesssubcommands.
The first two are RON files; the third is operator state. For every canonical term used here, see the glossary.
Architecture Overview
boatramp is a Rust workspace of feature-gated crates that compose into one binary:
| Crate | Responsibility |
|---|---|
boatramp-core | Domain types, the streaming Storage trait, the pluggable KvStore, content-addressed deploys, routing, config, access/WAF, messaging. No runtime/engine. |
boatramp-storage | Backends: FsStorage, S3, SlateDB KV, Cloudflare KV, libsql SQL. |
boatramp-server | The axum HTTP server: serving pipeline, control-plane API, auth, limits. |
boatramp-handlers | The wasmtime engine + host bindings for Wasm components. |
boatramp-acme | ACME (incl. DNS-01) + the DnsProvider abstraction. |
boatramp-cluster | openraft integration: RaftKv, RaftMessaging, persistence, membership. |
boatramp-firecracker | The microVM compute backend: an embedded rust-vmm VMM and an external-Firecracker driver, with snapshot/restore. |
boatramp-container | The container compute backend: a jailed worker with namespaces, cgroups, and a seccomp filter. |
boatramp-docker | The remote-Docker compute backend. |
boatramp-cloudflare | The Cloudflare Containers compute backend + edge-Worker generator. |
boatramp | The CLI (serve, sync, domain, …) and deploy generators. |
The ComputeBackend trait, scheduler, and reconcile loop live in
boatramp-core::compute; each backend above is a separate, capability-detected
crate. See Compute: handlers vs containers vs microVMs.
Two kinds of data
boatramp keeps two very different things apart, so nothing is ever buffered whole in memory:
- Blobs — file contents — stream through a
Storagebackend (fs / S3 / R2), content-addressed by SHA-256. - Metadata — small, read on every request — lives in a
KvStore(deploy manifests, the per-site current pointer, site config, tokens, certs).
See Storage & KV and the KV Keyspace.
The request pipeline
One ordered pipeline, each stage driven by config:
- Host → site (virtualhost), with an optional default site.
- TLS / transport — HTTPS redirect + HSTS (proxy-aware via
X-Forwarded-Proto). - Access control — WAF → IP rules → rate limit → basic auth.
- Path normalization — clean URLs, trailing-slash policy, dot-segment collapsing (traversal-safe).
- Redirects, then handlers, then rewrites / SPA / reverse-proxy.
- Resolve to a manifest entry (directory index, custom error documents).
- HTTP correctness — conditional
304,Range/206, ETag, headers,Cache-Control, compression negotiation.
The routing logic (steps 4–7) is pure and lives in boatramp_core::route, so it
is unit-tested in isolation — and reused by the Cloudflare edge Worker, so the
edge and the origin route identically.
Deployment modes, one UX
The same commands and config run on a single node, a self-hosted Raft cluster,
or Cloudflare Containers. Environment differences hide behind the Storage /
KvStore / Messaging trait seams, not in the UX. See
Deployment topologies.
Storage & KV
boatramp stores blobs in a streaming Storage backend and all control-plane
metadata in a KvStore. The KvStore trait is deliberately tiny
(get/put/delete/list_prefix/write_batch), and it plays three roles.
One trait, three roles
| Role | Implementors | What it is |
|---|---|---|
| Storage (durable) | SlateKv (SlateDB over local FS / S3 / R2 / GCS), CloudflareKv, MemoryKv | Where the bytes rest. |
| Consensus frontend | RaftKv | Turns writes into replicated Raft entries; serves reads from local applied state. Persists its log + state to a Storage backend per node. |
| Caching decorator | CachedKv | A write-through LRU in front of any KvStore. |
They compose: CachedKv(SlateKv), or RaftKv over a per-node SlateKv.
Two topologies (pick one)
Consensus (RaftKv)
Writes go to the leader, commit to the replicated log, and apply to every node’s state machine; reads come from local applied state. This is cluster mode and Cloudflare-Containers mode.
- Each node keeps its own durable Raft store — not shared (sharing a Raft log breaks Raft). Only blobs (S3/R2) are shared.
- No cache staleness, no
SIGHUP:RaftKvreads local applied state with no LRU in front.
Shared-store / no-consensus (CachedKv)
One backend is the source of truth and coherence is the store’s job. N stateless
frontends each front it with a local CachedKv; blobs are shared too.
- The shared store is itself replicated/consistent — Cloudflare KV, or a shared SlateDB-on-R2.
- A peer’s write isn’t visible until the local LRU evicts —
SIGHUP(or the changelog) forces the re-read. See Cache Coherence. - A single node on local disk is just this with one process; the cache never goes stale because nothing else writes.
SlateDB specifics
SlateDB is single-writer (manifest fencing). The shared-SlateDB topology is
therefore one writer process + read replicas (SlateKv::open_reader over
SlateDB’s DbReader), which serve reads and poll the manifest for new data;
control-plane writes funnel to the writer.
Selecting backends
--kv selects the storage; the frontend is consensus only if a [cluster]
config is present. --blobs selects the blob Storage. The per-site SQL
binding (libsql: a file per site, or a sqld namespace per site) is configured
under [handlers.bindings.sql].
Cache Coherence
This concerns only the shared-store / no-consensus topology — N stateless
processes over one shared KvStore, each fronting it with a local CachedKv
LRU. The Raft topology needs none of it (replication keeps every node’s applied
state current; RaftKv has no LRU). A single process doesn’t either.
The goal: a process picks up another process’s control-plane write promptly and cheaply, scaling to thousands of sites — without TTL desync and without flushing the world on every write.
Why not the obvious options
- Per-entry TTL — every entry goes stale on its own clock; you tune a guess and live with desync.
- Flush-all on any write — one site’s edit flushes every process’s whole LRU
→ all frontends re-fetch their working set → a thundering herd on every write.
Cost scales with
cache_size × write_rate. Kept only as a rare backstop.
Targeted invalidation via a changelog
Invalidate only the changed keys (pop site X’s entries; leave the others hot). Cost is O(write rate), independent of site count; O(1) per change.
On a control-plane write, one entry _inval/{millis}-{writer}-{n} listing the
changed keys is appended to the shared store. Each process polls for entries
after its cursor, pops those keys from its LRU, and advances the cursor (its own
entries are skipped). Old entries are trimmed; a rare full flush is the gap
backstop. The feed is just KV data, so it works over Cloudflare KV or shared
SlateDB alike. Enable with --shared-cache-coherence.
For real-time (poll-free) delivery, a pusher (a Cloudflare Durable Object /
Queue, Redis, or ops) can POST /api/cache/invalidate {keys:[…]} directly.
Minimizing the surface: content-addressed config
The fewer mutable keys, the smaller the problem. SiteConfig is
content-addressed: an immutable siteconfig/<hash> body (caches forever, dedups
across sites) plus a tiny mutable site/<site> pointer. Only the pointer changes
on an edit, so the feed carries pointers, not config bodies — and the bodies
never need invalidation at all. (This also makes config edits atomic pointer
flips, like deploy activation.)
What is never cached
Coordination state — rate-limit windows (ratelimit/<site>/<ip>) and messaging
claim/lease state (mqp/…) — is read through the uncached backend; caching
it would yield stale leases / wrong counts in shared mode.
The request pipeline
Every request for served content runs through one ordered pipeline. Each stage is driven by the site’s config, and the stages run in a fixed order so the behavior is predictable. Nothing is buffered whole in memory — the response streams from the backend as soon as the pipeline resolves it.
The order
- Host → site. The
Hostheader selects the site (virtualhost routing), with an optional default site for an unmatched host. The full set of ways a request is matched to a site is in How a request reaches your site. - Transport. HTTPS redirect and HSTS, proxy-aware through
X-Forwarded-Protofrom a trusted proxy. - Access control. WAF, then IP rules, then rate limit, then basic auth — the first to reject wins. See Restrict visitor access.
- Path normalization. Clean URLs, the trailing-slash policy, and dot-segment collapsing (traversal-safe).
- Route. Redirects, then handlers, then rewrites / SPA fallback / reverse-proxy.
- Resolve. Map the path to a manifest entry — a directory index, or a custom error document when nothing matches.
- HTTP correctness. Conditional
304,Range/206,ETag, response headers,Cache-Control, and compression negotiation.
An early stage can end the request — a rejected access-control check, a redirect, a handler that answers — before the later stages run.
Why the order is fixed
The order encodes precedence you would otherwise have to reason about per
request. Access control runs before any content work, so a blocked request never
touches the manifest. Redirects run before handlers, so a moved path does not
invoke code. Path normalization runs before routing, so route patterns match a
canonical path and cannot be bypassed with .. or a double slash.
The routing core is pure and shared
Stages 4 through 7 — normalization, routing, resolution, and HTTP correctness —
are pure functions in boatramp_core::route, with no I/O. That has two
consequences. They are unit-tested in isolation, against inputs rather than a
running server. And they are reused by the Cloudflare edge Worker, so a request
routes identically at the edge and at the origin — the two cannot drift, because
they run the same code. See the architecture overview.
How a request reaches your site
boatramp serves a site at a root mountpoint — the site’s files answer at /,
/assets/app.js, /api, exactly as they were authored. This page explains every
way a request is matched to a site, in the order you meet them: the local
single-site default, host/domain routing in production, the zero-DNS
<site>.localhost convenience, and the explicit by-name admin route.
The routing itself is one pure function shared by every deployment target, so a request resolves the same way on a single node, a cluster, or Cloudflare Containers. What differs is only which host names resolve to which site.
The single-site default (local first run)
When a server serves exactly one site, that site answers at the root of the
listener. Run boatramp serve, publish one site, and it is there:
curl http://127.0.0.1:8080/
No host header, no domain, no path prefix. This is the first-run experience in
Publish your first site: the site you just
published is the site at /. Publish a second site and the default turns off
(the server can no longer guess which one you mean) — then you address sites by
host, below.
Host / domain routing (production)
In production a site answers on a hostname you attach to it. The Host header of
each request selects the site; the request path is served at that host’s root. A
site can hold a primary hostname, exact aliases, and wildcards — see the
domains config.
boatramp domain add app.example.com --method dns
boatramp domain verify app.example.com
boatramp routes a host only after you prove you control it, so attaching is a
verify-then-route task — see Attach a custom domain.
Because selection rides the Host header, it behaves identically on every
topology; a domain is registered once and every node resolves it. A host that
matches no attached domain returns 404, unless a
default site or an explicit
--default-site catch-all is set.
<site>.localhost (zero-DNS local multi-site)
To work on several sites locally without editing DNS or /etc/hosts, address a
site by putting its name in the first host label. blog.localhost resolves to the
site named blog, served at root:
curl -H 'Host: blog.localhost' http://127.0.0.1:8080/
# or, so the browser/curl resolves it to loopback:
curl --resolve blog.localhost:8080:127.0.0.1 http://blog.localhost:8080/
Most resolvers (macOS, systemd-resolved) send *.localhost to loopback already,
so a browser can just visit http://blog.localhost:8080/. On systems that do not
(bare Windows, some musl setups), use --resolve or an explicit Host header —
that is a client resolver gap, not a difference in how boatramp behaves.
First-label routing never overrides a registered domain: an attached host always wins over a same-named label.
Note: the single-site default and
<site>.localhostrouting are conveniences for local and single-operator use. They are on for a loopback bind, and under thesingle-tenantanddevsecurity postures; they are off under the default strictmulti-tenantposture on a public address, where an unmatched host resolves only to an explicit--default-siteor404. This keeps a public multi-tenant server from ever resolvingHost: <sitename>.attacker.exampleto one of your sites by name.
/_sites/<name> (explicit by-name, admin/testing)
Every site is also reachable by name at /_sites/<name>/…, regardless of host.
This is an admin and testing affordance — a quick way to hit a specific site
without attaching a host:
curl http://127.0.0.1:8080/_sites/blog/
It is not a hosting model. Because the site’s content is served under a path
prefix, a site authored for root — with absolute references like /assets/app.js
or fetch('/api') — breaks here: those URLs resolve against the origin root, not
the /_sites/blog/ prefix. Use host routing (or the single-site default) to serve
such a site; reach for /_sites/<name> only for by-name inspection.
Deprecated: the older
/sites/<name>/…prefix is a deprecated alias for/_sites/<name>/…and still works for now. Prefer/_sites/.
Sub-path mounts
Serving a site under a deliberate sub-path (for a site built with a matching
base path, e.g. a framework’s base / basePath) is not available yet. Absolute
URLs authored for root cannot be rewritten server-side in the general case, so the
supported model is a root mountpoint via host routing. See
Maturity, validation & support for status.
Choosing
| You want | Use |
|---|---|
| A quick local first run | The single-site default — publish one site, hit /. |
| Several sites locally, no DNS | <site>.localhost (first-label routing). |
| Production on your own hostname | Attach a domain; the site answers at its host’s root. |
| To inspect a specific site by name | /_sites/<name>/ (admin/testing). |
Authentication & authorization
The control-plane API — publishing, config, tokens — authenticates every request. Public serving never does. This page explains the model: how a credential is signed, how a request is authorized, and how a token can be narrowed offline. For the tasks, see Bootstrap authentication; for the right vocabulary, see RBAC roles, actions & resources.
Tokens are signed claim sets
A boatramp token is a COSE_Sign1 structure over a CWT claim set (RFC 8392 /
9052). The claims name the granted roles, an expiry, and a revocation id; the
whole thing is signed by the control plane’s root key. This has one property that
shapes the rest of the design: verifying a token needs only the public key.
There is no per-request database lookup — a node checks the signature and the
expiry against a public key it holds, decides the request, and moves on. Every
node can authorize independently, including read replicas that never mint
anything.
Revocation is the one piece that is not purely offline: a revoked token’s id is recorded, and the verify path rejects it. That check is a small keyed lookup, not a signature-scale cost.
Authorization is Cedar RBAC
Once a token verifies, the request is authorized with
Cedar. Cedar decides whether the token’s granted
roles carry a right — an action (read, write, deploy, admin) on a
resource (site, blobs, tokens, certs, cache, system), optionally
scoped to one site — 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 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.
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.
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-tenantassumes one operator who owns every site and relaxes the knobs that only matter between mutually-distrusting tenants.devassumes local development and loosens loopback-only conveniences.
Overrides layer individual knobs on top of a profile, so you start from a
coherent baseline and adjust one thing without silently loosening others. Because
the knob is the unit of enforcement, boatramp security explain can always show
the resolved value and its source — profile or override.
The default is strict on purpose
The multi-tenant default fails closed: a non-loopback bind refuses to start
without auth, uploads and components are capped, private upstreams and
shared-kernel compute are denied. An operator who wants less must say so
explicitly. That ordering — safe by default, dangerous only on request — is the
whole point of having a posture rather than a pile of independent flags.
To set and inspect one, see Choose & inspect a security posture.
Compute: handlers vs containers vs microVMs
boatramp runs application code three ways. They differ in isolation, startup cost, and what code they can run. Pick the lightest one that fits.
The three options
Wasm handler — a WebAssembly component bound to a route. It 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. Reach for a
handler first — see Deploy a handler.
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 — the same OCI image run inside a Firecracker-class virtual machine
with its own kernel. 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.
Choosing
| Wasm handler | Container | microVM | |
|---|---|---|---|
| Isolation | in-process capability sandbox | shared kernel + namespaces | own kernel (hardware) |
| Startup | sub-millisecond | fast | boot (or restore) |
| Runs | wasi:http components | any Linux program | any Linux program |
| Trust | any | code you trust | untrusted / tenant code |
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 cluster, 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. Blob
and metadata durability move to R2 and D1 behind the same Storage / KvStore
seams. See Deploy on Cloudflare Containers.
Choosing
- One host, most sites → single node.
- Read scale without HA writes → shared-store frontends.
- Highly available control-plane writes, multi-region reads → cluster.
- Cloudflare’s edge and managed backends → Cloudflare Containers.
The choice is an operational one. Because it is a backend choice behind the seam, you can start on one node and move to a cluster later without rewriting anything.
Maturity, validation & support
boatramp is pre-1.0. The core is feature-complete and tested; some capabilities that depend on real cloud or multi-host environments are validated at the mechanism level and have a remaining live-operation seam. This page states, per capability, what “done” means so you can judge what to run in production.
What “validated” means here
Every capability has unit and integration tests that run in CI, plus native
validation of its mechanism. Some also have a live seam — an #[ignore]d test or
an operational path that needs a real cluster, cloud account, or KVM host to
exercise end to end. A live seam means the code is written and the mechanism is
proven; the remaining work is real-environment operation, not implementation.
Status by capability
| Capability | Status |
|---|---|
| Static hosting, atomic deploys & rollback | Stable. |
| Routing (redirects, rewrites, headers, SPA) | Stable. |
| Domains, TLS, ACME (HTTP-01 + DNS-01) | Stable. |
| Auto-DNS (10 managed providers) | Stable; each cloud provider’s live round-trip is a per-provider seam (Cloudflare validated against a real zone). |
| Authentication, RBAC, external signers | Stable; KMS/HSM/Vault backends have live seams for the specific service. |
| Wasm handlers + host bindings | Stable. |
| Caching, compression, observability | Stable. |
| Single-node deployment | Stable. |
| Clustering (Raft) | In-process complete; live multi-host operation is the remaining seam. |
| Compute — containers & microVMs | The backends and the embedded VMM boot and serve real images; scale-to-zero snapshot/restore is validated live. The automatic idle→snapshot reconcile and VMM persistent volumes are being finished. |
| Cloudflare Containers target | Declarative generate + deploy is complete; live cloud deploy/scale is a beta seam. |
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 lists the commands and details the flags of serve; each command also
prints its own flags with boatramp <command> --help.
Precedence for any overridable value: flag / environment variable > config
file > built-in default. Project commands read project.cfg; serve reads
boatramp.cfg.
Global flags
| Flag | Default | Description |
|---|---|---|
--config <path> | project.cfg / boatramp.cfg | Config file to read. |
-h, --help | — | Print help for the binary or a subcommand. |
-V, --version | — | Print the version. |
Commands
| Command | What it does |
|---|---|
serve | Run the HTTP server and publishing API. |
sync <dir> | Build (optional) and publish a folder as a new atomic deployment. |
build | Run the configured build command only. |
bundle | Bundle JS/TS + CSS in-process (bundler feature). |
validate | Parse and check a project.cfg (its routing section). |
deployments | List a site’s deployment history. |
rollback | Roll back to the previous (or a specific) deployment. |
status | Show a site’s current deployment (id, age, size). |
domain | Attach or detach hostnames for a site. |
alias | Manage named pointers (staging, previews) to deployments. |
access | Configure visitor access control (basic auth, IP rules, rate limit). |
token | Manage control-plane API tokens. |
cluster | Operate a cluster’s mesh membership (mint join tokens). |
security | Inspect the operator security posture (security explain). |
auth | Generate or inspect the control-plane root key. |
gateway | Publish a private service through the edge reverse proxy. |
compute | Manage microVM / container compute workloads. |
dns | Configure DNS and issue wildcard preview certs (acme-dns feature). |
logs | Tail a site’s captured guest stdout/stderr. |
stats | Show handler invocation stats, consumer lag, and dead letters. |
dlq | Purge or redrive a consumer topic’s dead-letter queue. |
prune | Delete orphan deployments and unreferenced blobs. |
scrub | Verify every stored blob still hashes to its key. |
cert-status | Show cluster-managed certificate status (domain, expiry). |
completions <shell> | Print a shell-completion script. |
man | Render the man page to stdout. |
cloudflare | Generate a Cloudflare Containers deployment (cluster feature). |
Each command’s tasks are covered in the guides — see the How-to
guides and the per-topic reference pages. 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.
Address, storage, cache
| Flag | Env | Default | Description |
|---|---|---|---|
--addr <host:port> | BOATRAMP_ADDR | 127.0.0.1:8080 | Bind address. |
--data-dir <path> | BOATRAMP_DATA_DIR | ./data | Blob + KV root for the filesystem backends. |
--blobs <fs|s3> | — | fs | Blob backend (s3 needs --features s3). |
--kv <slatedb|memory|cloudflare> | — | slatedb | KV backend (cloudflare needs --features cloudflare-kv). |
--s3-bucket <name> | BOATRAMP_S3_BUCKET | — | S3 bucket (--blobs s3). |
--s3-endpoint <url> | BOATRAMP_S3_ENDPOINT | — | S3 endpoint (MinIO / R2). |
--s3-region <region> | BOATRAMP_S3_REGION | — | S3 region. |
--s3-path-style | BOATRAMP_S3_PATH_STYLE | false | Use path-style S3 addressing. |
--cache-entries <n> | — | 256 | Front metadata cache size. |
Authentication
| Flag | Env | Default | Description |
|---|---|---|---|
--auth-root-private-key <alg:hex> | BOATRAMP_AUTH_ROOT_PRIVATE_KEY | — | Root key: verify and mint tokens. |
--auth-root-public-key <alg:hex> | BOATRAMP_AUTH_ROOT_PUBLIC_KEY | — | Root key: verify only. |
--bootstrap-secret <secret> | BOATRAMP_BOOTSTRAP_SECRET | — | Single-use secret enabling token bootstrap. |
--oidc-issuer <url> | BOATRAMP_OIDC_ISSUER | — | Enable OIDC → token exchange for this issuer. |
--oidc-audience <aud> | BOATRAMP_OIDC_AUDIENCE | — | Required audience claim. |
--oidc-scope-claim <name> | BOATRAMP_OIDC_SCOPE_CLAIM | — | Claim mapped to boatramp roles. |
Warning: with no root key, control-plane auth is disabled. Under the default
multi-tenantposture,serverefuses to start that way on a non-loopback--addr. Configure a key, bind127.0.0.1, or select a looser security posture.
TLS
| Flag | Default | Description |
|---|---|---|
--tls <off|custom|acme|acme-dns> | off | TLS mode (HTTPS needs the tls feature). |
--tls-cert <path> / --tls-key <path> | — | Certificate + key for --tls custom. |
--acme-domain <domain> | — | Domain to issue for (repeatable). |
--acme-directory <url> | Let’s Encrypt production | ACME directory URL. |
--acme-contact <email> | — | ACME account contact. |
--acme-ca-cert <path> | — | Extra CA root (for a private ACME CA). |
--acme-cache <path> | ./data/acme | Certificate cache directory. |
--acme-dns-provider <name> | manual | DNS-01 provider (--tls acme-dns); see DNS providers. |
--acme-wildcard-preview | false | Also issue *.deploy.<domain> for by-id previews. |
--http-redirect-addr <host:port> | BOATRAMP_HTTP_REDIRECT_ADDR | Second listener that 308s plain HTTP to HTTPS. |
Uploads, serving, cluster
| Flag | Env | Default | Description |
|---|---|---|---|
--max-upload-bytes <n> | BOATRAMP_MAX_UPLOAD_BYTES | unlimited | Reject larger blob uploads. |
--upload-idle-timeout-secs <n> | BOATRAMP_UPLOAD_IDLE_TIMEOUT | — | Abort an upload idle this long. |
--max-concurrent-uploads <n> | BOATRAMP_MAX_CONCURRENT_UPLOADS | — | Cap simultaneous uploads. |
--default-site <name> | BOATRAMP_DEFAULT_SITE | — | Site served for an unmatched Host. |
--protect-previews | BOATRAMP_PROTECT_PREVIEWS | false | Require a token to view /_deploy previews. |
--cluster-rate-limit | BOATRAMP_CLUSTER_RATE_LIMIT | false | Rate-limit cluster-wide via the KV, not per node. |
--shared-cache-coherence | BOATRAMP_SHARED_CACHE_COHERENCE | false | Keep the config cache coherent across processes sharing one KV. |
The cluster: and compute: sections are configured in
boatramp.cfg, not on the command line.
Example:
boatramp serve --config boatramp.cfg \
--addr 0.0.0.0:8080 --tls acme --acme-domain pad.example.com
control-plane auth enabled (issuer)
serving https://0.0.0.0:8080 — data /var/lib/boatramp
project.cfg schema
project.cfg is the per-project config, read by the client commands (sync,
build, bundle, validate). It is RON, lives
in the project folder, and is optional — a missing file means all defaults.
(
publish: ( server: "https://pad.example.com", site: "my-site" ),
build: ( command: "npm run build", output: "dist" ),
routing: (
clean_urls: true,
redirects: [ (from: "/old/:slug", to: "/new/:slug", status: 301) ],
),
)
Sections:
| Section | Purpose |
|---|---|
publish | Where and what to publish (sync). |
build | An optional build command run before sync. |
bundle | The in-process JS/CSS bundler (bundler feature). |
routing | Redirects, rewrites, headers, handlers — folded into the deployment. |
publish
| Field | Type | Description |
|---|---|---|
server | url | Server base URL. Flag --server, env BOATRAMP_SERVER. |
site | string | Site to publish to. Flag --site, env BOATRAMP_SITE. |
token | string | Control-plane token. Prefer BOATRAMP_TOKEN so it is not on disk. |
build
Run before sync; its output directory is what gets published.
| Field | Type | Description |
|---|---|---|
command | string | Shell command to run (e.g. npm run build). |
output | string | Directory the build emits and sync publishes (e.g. dist). |
bundle
The in-process bundler (Rolldown for JS/TS, lightningcss for CSS). Needs the
bundler feature.
| Field | Type | Default | Description |
|---|---|---|---|
outdir | string | dist | Output directory for bundled assets. |
js | list | — | JS/TS entry points (tree-shaken, code-split). |
css | list | — | CSS entry points (@import inlined). |
minify | bool | true | Minify the output. |
routing
The bulk of a project’s config: redirects, rewrites, headers, SPA fallback,
clean URLs, error documents, and the handler/consumer/cron/stream declarations.
It is compiled and checked at sync (and by boatramp validate), then folded
into the immutable deployment manifest — so it is atomic with the content and
rolls back with it.
The full field-by-field schema is on its own page: Routing config schema.
Validate a project.cfg (including routing) without publishing:
boatramp validate
project.cfg: routing OK (2 redirects, 1 handler)
boatramp.cfg schema
boatramp.cfg is the server config, read by boatramp serve. It is
RON. Every value can also be set as a flag or an
environment variable, which take precedence. The whole file is optional — serve
runs with defaults without it.
boatramp serve --config boatramp.cfg
Precedence for any value: flag / environment variable > boatramp.cfg >
built-in default.
Top-level sections, all optional:
| Section | Purpose |
|---|---|
serve | Bind address, data dir, auth keys, upload limits. |
security | Operator security posture (profile + per-knob overrides). |
secrets | Envelope encryption for cert private keys at rest. |
handlers | Wasm handler runtime (needs the handlers feature). |
cluster | Self-hosted Raft cluster (needs the cluster feature). |
compute | Container / microVM execution backends. |
serve
| Field | Type | Default | Description |
|---|---|---|---|
addr | socket address | 127.0.0.1:8080 | Bind address. Env BOATRAMP_ADDR. |
data_dir | path | ./data | Root for the filesystem blob + KV backends. Env BOATRAMP_DATA_DIR. |
auth_root_private_key | "<alg>:<hex>" | — | Root signing key: this node verifies and mints tokens. Env BOATRAMP_AUTH_ROOT_PRIVATE_KEY. |
auth_root_public_key | "<alg>:<hex>" | — | Root verify key: this node verifies only, cannot mint. Env BOATRAMP_AUTH_ROOT_PUBLIC_KEY. |
bootstrap_secret | string | — | Single-use secret enabling token bootstrap. Prefer the env var / flag so it is not written to disk. Env BOATRAMP_BOOTSTRAP_SECRET. |
signer | signer enum | — | External signer (KMS/HSM/Vault) in place of an in-process key. See below. |
max_upload_bytes | integer | unlimited | Reject blob uploads larger than this. |
default_site | string | — | Site served for a Host matching no domain, instead of 404. |
protect_previews | bool | false | Require a control-plane token to view /_deploy previews. |
Warning: with no
auth_root_*key configured, control-plane auth is disabled. Under the defaultmulti-tenantposture,serverefuses to start that way on a non-loopbackaddr. Configure a key, bind127.0.0.1, or select a looser security posture.
serve.signer
Selects an external signer so the root key never sits in process memory. Written as a RON enum. Credentials (tokens, PINs) come from the named environment variables, never this file.
| Variant | Fields |
|---|---|
Local | private_key: "<alg>:<hex>" |
Vault | address, key, token_env, alg (Es256 | Ed25519) |
AwsKms | key_id, region (optional) |
GcpKms | key_version, access_token_env |
AzureKv | vault_url, key, key_version, access_token_env |
Pkcs11 | module, token_label, key_label, pin_env, alg |
serve: ( signer: Vault(
address: "https://vault:8200",
key: "boatramp-root",
token_env: "VAULT_TOKEN",
alg: Es256,
) )
See Hold the signing key in a KMS/HSM/Vault.
security
The operator security posture: a profile preset plus per-knob overrides. Absent
means the strict multi-tenant default. This section is operator-only — it is
never part of site config, so a site writer cannot relax it. Inspect the resolved
posture with boatramp security explain.
| Field | Type | Default | Description |
|---|---|---|---|
profile | string | multi-tenant | multi-tenant (strict), single-tenant (one trusted operator), dev (loopback-loose), or a name from profiles. |
overrides | knob table | — | Individual knobs; a knob is the source of truth, a profile is sugar. |
profiles | map | — | Custom named profiles, each a set of overrides over the strict baseline. |
Override knobs (byte caps: 0 = unlimited):
| Knob | Description |
|---|---|
allow_unauthenticated_public_bind | Permit a non-loopback bind with auth off. |
max_upload_bytes | Blob upload cap. |
allow_site_unix_upstreams | Let a site’s gateway target unix: sockets. |
allow_site_private_upstreams | Let a site’s gateway target private IPs. |
max_handler_blob_bytes | Per-handler blobstore write cap. |
max_component_bytes | Wasm component size cap. |
oidc_require_audience | Require an aud claim on OIDC exchange. |
domain_verify_allow_private | Allow domain-verification probes to private hosts. |
allow_shared_kernel_compute | Permit container (shared-kernel) compute; off ⇒ microVM only. |
ratelimit_fail_open | Serve rather than reject if the rate-limit store is unavailable. |
allow_implicit_routing | Resolve an unmatched host to a site without a registered domain (first-label <site>.host / sole site). Off under multi-tenant; a loopback bind enables it regardless. See addressing. |
See Choose & inspect a security posture and The security posture model.
secrets
Envelope-encrypt cluster-managed certificate private keys so they are never cleartext in the replicated control plane. Absent means keys are stored cleartext.
| Field | Type | Description |
|---|---|---|
envelope | string | local (machine-local AES-256-GCM KEK) or vault (Vault Transit). |
kek_file | path | Local KEK file (auto-generated 0600). In a cluster the same file must be on every node. |
vault | table | For envelope: "vault": addr, key (a Transit key), token_env. |
handlers
Wasm handler runtime. Parsed always, consumed only with the handlers feature.
| Field | Type | Default | Description |
|---|---|---|---|
pooling | bool | false | Use the wasmtime pooling allocator (faster instantiation, large virtual-memory reservation). |
bindings.sql | table | — | The sql host binding. Omit for single-node (a per-site embedded libsql file); set url for a shared sqld. |
bindings.sql fields: dir, url, admin_url, replica_url, token_env,
admin_token_env, preview_mode (empty | branch | shared),
preview_init. See Use handler bindings.
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.
| Field | Type | Default | Description |
|---|---|---|---|
node_id | integer | — | This node’s stable id, unique in the cluster. |
listen | socket address | — | Bind for the Raft peer mesh (distinct from serve.addr). |
peers | map | — | id → (url, pubkey) for every node. The pubkey (logged at startup) seeds the mesh trust set. |
voters | list of ids | all peers | The voting quorum; peers not listed join as learners. |
store_dir | path | <data-dir>/raft | This node’s durable Raft store. Never shared between nodes. |
bootstrap | bool | false | Set on exactly one node at first bring-up. |
mesh | table | — | Mesh identity + TLS: key_file, key_rotation, join_token_ttl, gate_client_writes. |
Warning: a non-loopback
listenrefuses to start until every node’spubkeyis inpeers. Never point two nodes at onestore_dir.
See Deploy a self-hosted cluster.
compute
Container / microVM execution backends. Present ⇒ this node advertises compute
capacity to the scheduler; backends are capability-detected (container on Linux,
microVM where /dev/kvm exists).
| Field | Type | Default | Description |
|---|---|---|---|
bridge | string | br-boatramp | Bridge the guest veths / VM taps attach to. |
subnet | string | 10.0.0.0/24 | Guest IP subnet. |
vcpus | integer | detect | vCPUs this node advertises as schedulable (0 = detect). |
mem_mib | integer | 1024 | Memory (MiB) advertised as schedulable (0 = 1 GiB). |
See Run a container or microVM.
Routing config schema
The routing section of project.cfg is the deploy-scoped config tier. It
is authored in RON, parsed at sync, and folded
into the immutable deployment manifest — so it is atomic with the content and
rolls back with it. Every field is optional; an empty routing: () is all
defaults.
Validate it without publishing:
boatramp validate
project.cfg: routing OK (2 redirects, 1 handler)
Top-level fields
| Field | Type | Default | Description |
|---|---|---|---|
version | u32 | 1 | Schema version, pinned at 1. |
index | list<string> | ["index.html"] | Directory-index candidates, tried in order. |
clean_urls | bool | false | Map extensionless URLs to .html (/about → /about.html). |
case_insensitive | bool | false | Match paths case-insensitively against redirects, rewrites, and files. |
trailing_slash | enum | Preserve | Trailing-slash policy — see below. |
error_documents | map<u16, string> | {} | Status code → error document (404: "/404.html"). |
redirects | list<Redirect> | [] | Redirect rules, first match wins. |
rewrites | list<Rewrite> | [] | Internal-rewrite or reverse-proxy rules, first match wins. |
headers | list<HeaderRule> | [] | Response-header rules; every matching rule applies, in order. |
cache | CacheConfig | — | Default Cache-Control — see below. |
mime_overrides | map<string, string> | {} | Extension → MIME override (".webmanifest": "..."). |
proxy_allow | list<string> | [] | Allowed upstream hosts for proxy rewrites — see below. |
handlers | list<HandlerConfig> | [] | WebAssembly request handlers, matched after redirects, before static lookup. |
consumers | list<ConsumerConfig> | [] | Message-consumer components, invoked per message on a topic. |
crons | list<CronConfig> | [] | Scheduled handler invocations. |
streams | list<StreamConfig> | [] | Host-level SSE / WebSocket endpoints fanning out topics. |
Pattern fields (from, matches, handler route) use the
path matcher syntax and are compiled at validate/sync, so a bad
pattern fails at deploy time rather than at request time.
trailing_slash
| Value | Effect |
|---|---|
Preserve | Leave the path as-is (default). |
Always | Redirect to add a trailing slash. |
Never | Redirect to strip a trailing slash. |
redirects
Each rule redirects a matching path. First match wins.
| Field | Type | Default | Description |
|---|---|---|---|
from | pattern | — | Source path pattern. |
to | string | — | Destination, with :name / :splat substitution. |
status | u16 | 308 | HTTP status. 308 is permanent and method-preserving. |
redirects: [ (from: "/old/:slug", to: "/new/:slug", status: 301) ],
rewrites
A rewrite serves a different resource without changing the URL. An internal
to (a path) rewrites; an absolute-URL to reverse-proxies to that upstream.
First match wins.
| Field | Type | Default | Description |
|---|---|---|---|
from | pattern | — | Source path pattern. |
to | string | — | Internal path or absolute proxy URL, with :name / :splat substitution. |
status | u16 | 200 | Status served for an internal rewrite (e.g. 200 for SPA fallback). |
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.
headers
Each rule sets or removes response headers on matching paths. All matching rules apply, in order.
| Field | Type | Description |
|---|---|---|
matches | pattern | Path pattern (named matches because for is a keyword). |
set | map<string, string> | Headers to set. |
unset | list<string> | Header names to remove. |
headers: [ (matches: "/assets/*", set: { "Cache-Control": "public, max-age=31536000, immutable" }) ],
cache
| Field | Type | Description |
|---|---|---|
default | string? | Default Cache-Control for responses not covered by a header rule. |
proxy_allow
Upstream hosts a proxy rewrite may target. An entry is an exact host or a
.suffix for a subtree (.internal.example.com). When the list is empty,
proxying to any public host is allowed; private, loopback, and link-local
addresses are always blocked as an SSRF guard, regardless of this list. To proxy
to a private address, declare a gateway upstream
instead.
handlers
A WebAssembly handler bound to a route. Matched after redirects, before static lookup. See Deploy a handler.
| Field | Type | Default | Description |
|---|---|---|---|
route | pattern | — | Route pattern. |
methods | list<string> | [] (all) | HTTP methods answered (GET, POST, …). |
component | string | — | Path to the component .wasm within the deployment. |
imports | list<string> | [] | Requested capabilities — see imports. |
limits | HandlerLimits | — | Optional resource caps, intersected with the site caps at activation. |
env | map<string, string> | {} | Static environment variables. Never secrets — a credential-shaped value is rejected at validate; use [handlers].secrets in boatramp.cfg for those. |
imports
The capability vocabulary a handler may request. An unrecognized import is rejected at validate.
| Import | Grants |
|---|---|
wasi:http | Outbound HTTP. |
wasi:keyvalue | Per-site KV store. |
wasi:blobstore | Per-site blob store. |
wasi:messaging | Publish / subscribe on topics. |
sql | Per-site SQL database. |
wasi:io, wasi:clocks, wasi:random, wasi:logging | Standard host facilities. |
The site’s allow_imports is the allowlist; a
handler requesting an import the site does not permit is denied at activation.
limits (HandlerLimits)
| Field | Type | Description |
|---|---|---|
memory_mb | u32? | Max linear memory, MiB. |
timeout_ms | u32? | Wall-clock timeout, ms. |
fuel | u64? | CPU budget in wasmtime fuel units (deterministic instruction-count bound). Omitted = unmetered. |
Each field may only lower the corresponding site cap, never raise it.
consumers
A component invoked once per message on a topic. See Run consumers, crons, and streams.
| Field | Type | Description |
|---|---|---|
topic | string | Topic to subscribe to (namespaced). |
component | string | Path to the component .wasm. |
imports | list<string> | Requested capabilities. |
crons
A scheduled invocation of a declared handler route.
| Field | Type | Default | Description |
|---|---|---|---|
schedule | string | — | Standard 5-field cron (minute hour dom month dow). |
route | string | — | Handler route to invoke; must be served by a declared handler. |
overlap | enum | Skip | Skip a tick if the previous run is still in flight, or Allow concurrent runs. |
streams
A host-level endpoint that fans out messaging topics to connected clients.
| Field | Type | Default | Description |
|---|---|---|---|
route | string | — | Route the endpoint is served at. |
topics | list<string> | — | Topics broadcast to clients (server→client). |
websocket | bool | false | Serve as a WebSocket instead of SSE (adds a client→server direction). |
publish_topic | string? | — | For a WebSocket, the topic client→server messages publish to. Omitted = receive-only. |
Patterns
Route, redirect, rewrite, and header patterns share one matcher syntax:
| Token | Matches | Capture |
|---|---|---|
:name | One path segment | :name in to |
* / /* | The rest of the path | :splat in to |
| literal | Itself | — |
Path normalization (dot-segment collapsing, the trailing-slash policy) runs
before matching, so patterns always see a canonical path and cannot be bypassed
with .. or a double slash. See The request pipeline.
SiteConfig schema
SiteConfig is the site-scoped, mutable config tier: domains, transport
security, visitor access control, handler caps, compression, and the gateway. It
is stored as JSON in the KV (not in a deployment manifest), so it changes
independently of content and does not roll back with a deployment. Most of it is
managed through subcommands rather than edited by hand.
The tiers, contrasted:
Routing (project.cfg) | SiteConfig (KV) | |
|---|---|---|
| Scope | One deployment | The whole site |
| Lifecycle | Immutable, rolls back with content | Mutable, independent |
| Edited via | project.cfg + sync | boatramp domain / access / gateway / API |
Top-level fields
| Field | Type | Default | Managed by |
|---|---|---|---|
version | u32 | 1 | — (pinned at 1) |
domains | DomainConfig | empty | boatramp domain |
security | SecurityConfig | off | API / transport security |
access | AccessConfig | open | boatramp access |
handlers | HandlersSiteConfig? | None (disabled) | handler caps |
compression | CompressionConfig | off | boatramp compression |
gateway | GatewayConfig? | None | boatramp gateway |
domains
The hostnames a site answers to (virtualhost routing). See Serve a custom domain.
| Field | Type | Default | Description |
|---|---|---|---|
primary | string? | — | Canonical hostname (example.com). |
aliases | list<string> | [] | Additional exact hostnames (www.example.com). |
wildcards | list<string> | [] | Wildcard patterns (*.example.com), matched by suffix at any depth. |
canonical_redirect | bool | false | 301 exact-alias hosts to primary (apex↔www). Wildcard hosts serve as-is. |
security
Site-tier transport security. Off by default; opt in once TLS is in front
(directly or via a terminating proxy). The effective scheme is read from
X-Forwarded-Proto behind a trusted proxy. See
Harden the security posture.
| Field | Type | Default | Description |
|---|---|---|---|
https_redirect | bool | false | 301 plain-HTTP requests to HTTPS. |
hsts | Hsts? | — | Send Strict-Transport-Security on HTTPS responses. |
csp | string? | — | Content-Security-Policy header value (opt-in; no safe default for static sites). |
frame_options | string? | — | X-Frame-Options value (DENY, SAMEORIGIN). |
hsts
| Field | Type | Default | Description |
|---|---|---|---|
max_age | u64 | 31536000 | max-age in seconds (one year). |
include_subdomains | bool | true | Apply to subdomains. |
preload | bool | false | Request browser-preload-list inclusion (hard to undo — explicit opt-in). |
access
Visitor access control — WAF, IP rules, rate limiting, basic auth, trusted-proxy
handling. This is the full mechanism for restricting who may view a site; it is
separate from control-plane RBAC. Managed with boatramp access and
documented in Restrict visitor access.
handlers
Site-scoped handler policy: the capability allowlist and resource caps a
deployment’s requested handler config is intersected
against at activation (deny by default). None disables handlers for the site
entirely.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Whether handlers run for this site at all. |
allow_imports | list<string> | [] | Interfaces handlers on this site may import (subset of the import vocabulary). |
max_memory_mb | u32? | — | Cap on per-handler memory (MiB). |
max_timeout_ms | u32? | — | Cap on per-handler wall-clock timeout (ms). |
max_concurrency | u32? | — | Cap on concurrent invocations for the site. |
max_fuel | u64? | — | Cap on per-handler CPU fuel; a handler’s own fuel may only lower it. |
secrets | map<string, string> | {} | Env-var name → secret reference (a host env-var name, resolved server-side — never a literal secret). |
background_aliases | list<string> | [] | Named aliases (besides current) whose deployments also run consumers and crons. See Run background work. |
max_stream_connections | u32? | — | Cap on concurrent SSE/WebSocket connections for the site. |
max_log_rate | u32? | — | Cap on captured guest log lines per second (over-cap lines are dropped, counted). |
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.
compression
On-the-fly response compression. Opt-in, and complementary to serving a
precompressed variant. A response is compressed only when it has no precompressed
variant or existing Content-Encoding, its type is compressible, and (when the
length is known) it is at least min_size. Credentialed responses are skipped
for BREACH safety. See Compress responses.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Master toggle. |
min_size | u64 | 1024 | Don’t compress a response with a Content-Length below this (bytes). Streaming responses with no declared length are always eligible. |
gateway
Reverse-proxy gateway for publishing private services. None means no gateway
routes. Declaring an upstream here is what authorizes reaching a private address
— the SSRF guard stays public-only otherwise. Fields
cover upstream pools, load balancing, and health checking; see
Expose a private service through the gateway.
Environment variables
boatramp reads its configuration from three places, in precedence order: command-line flag > environment variable > config file. Every variable below overrides the corresponding config field and is itself overridden by an explicit flag. Secrets (tokens, signing keys) belong in the environment rather than in a config file on disk.
Client commands
Read by sync, build, bundle, and the other project commands. See
project.cfg.
| Variable | Overrides | Description |
|---|---|---|
BOATRAMP_SERVER | publish.server | Server base URL. |
BOATRAMP_SITE | publish.site | Site to publish to. |
BOATRAMP_TOKEN | publish.token | Control-plane token. Prefer the env var so it is never on disk. |
Server (serve)
Read by boatramp serve. Each maps to a serve.* field in
boatramp.cfg; the flag of the same name wins over both.
| Variable | Description |
|---|---|
BOATRAMP_ADDR | Address to bind (e.g. 0.0.0.0:8080). |
BOATRAMP_DATA_DIR | Data directory (blobs + embedded KV). |
BOATRAMP_DEFAULT_SITE | Site to serve for an unmatched Host instead of 404. |
BOATRAMP_HTTP_REDIRECT_ADDR | In a TLS mode, a second plain-HTTP listener that 308-redirects to HTTPS (e.g. 0.0.0.0:80). |
BOATRAMP_PROTECT_PREVIEWS | Require a valid token to view deployment previews. |
BOATRAMP_LOG_FORMAT | json for structured logs (anything else = human-readable). |
Upload limits
| Variable | Description |
|---|---|
BOATRAMP_MAX_UPLOAD_BYTES | Reject blob uploads larger than this (default: unlimited). |
BOATRAMP_UPLOAD_IDLE_TIMEOUT | Abort an upload stalled this many seconds (slowloris guard). |
BOATRAMP_MAX_CONCURRENT_UPLOADS | Cap simultaneous uploads; further uploads get 503 until a slot frees. |
Authentication & tokens
See Bootstrap authentication and Authentication & authorization.
| Variable | Description |
|---|---|
BOATRAMP_AUTH_ROOT_PUBLIC_KEY | The trust anchor. Every node needs it to verify tokens. |
BOATRAMP_AUTH_ROOT_PRIVATE_KEY | The signing key. Needed only where tokens are minted; keep it off verify-only nodes. |
BOATRAMP_BOOTSTRAP_SECRET | Single-use secret that mints the first admin token, then is retired. |
BOATRAMP_HOLDER_KEY | Holder private key used to sign an offline delegation with token attenuate. |
An external signer (KMS/HSM/Vault) replaces BOATRAMP_AUTH_ROOT_PRIVATE_KEY
with its own credentials — see
Hold the signing key in a KMS/HSM/Vault.
OIDC federation
For exchanging an identity-provider JWT for a boatramp token. See Federate CI auth with OIDC.
| Variable | Description |
|---|---|
BOATRAMP_OIDC_ISSUER | Trusted issuer URL (its JWKS is fetched for verification). |
BOATRAMP_OIDC_AUDIENCE | Required audience claim. |
BOATRAMP_OIDC_SCOPE_CLAIM | Claim carrying the granted roles. |
Cluster & shared-store frontends
| Variable | Description |
|---|---|
BOATRAMP_CLUSTER_RATE_LIMIT | Rate-limit cluster-wide via the shared KV instead of per-node buckets. |
BOATRAMP_SHARED_CACHE_COHERENCE | Keep local config caches coherent across frontends sharing one KV. See Cache coherence. |
BOATRAMP_S3_BUCKET | Object-store bucket backing the KV (SlateDB over S3/R2). |
BOATRAMP_S3_ENDPOINT | S3-compatible endpoint URL. |
BOATRAMP_S3_REGION | Bucket region. |
BOATRAMP_S3_PATH_STYLE | Use path-style addressing (for non-AWS endpoints). |
Handler backends
| Variable | Description |
|---|---|
BOATRAMP_SQL_TOKEN | Auth token for a remote libsql database referenced by the SQL binding. |
BOATRAMP_FC_* | Embedded-VMM / Firecracker compute-backend settings (kernel, rootfs, bridge, subnet, …). See Run compute workloads. |
BOATRAMP_VMM_SERIAL | Attach the microVM serial console (debugging). |
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": "..." }.401is a missing or invalid token;403is a valid token without the required right.
Sites & deployments
| Method | Path | Purpose |
|---|---|---|
GET | /api/sites | List sites. |
POST | /api/sites/:site/deployments | Create a deployment from a manifest. |
GET | /api/sites/:site/deployments | List a site’s deployments. |
GET | /api/sites/:site/deployments/:id | Get one deployment. |
POST | /api/sites/:site/deployments/:id/activate | Make a deployment the live one. |
GET | /api/sites/:site/current | The currently active deployment. |
GET/PUT | /api/sites/:site/config | Read / replace the site config. |
GET/PUT/DELETE | /api/sites/:site/aliases/:name | Manage named aliases. |
GET | /api/sites/:site/aliases | List aliases. |
Blobs
| Method | Path | Purpose |
|---|---|---|
PUT | /api/blobs/:hash | Upload a content-addressed blob (raw body; the server verifies the hash). |
Domains
| Method | Path | Purpose |
|---|---|---|
GET/POST/DELETE | /api/sites/:site/domains/:host/verification | Manage a domain-ownership challenge. |
POST | /api/sites/:site/domains/:host/verification/check | Check the challenge. |
GET | /api/sites/:site/domain-verifications | List pending verifications. |
Tokens
| Method | Path | Purpose |
|---|---|---|
POST/GET | /api/tokens | Mint / list tokens. |
DELETE | /api/tokens/:id | Revoke a token by its id. |
POST | /api/tokens/bootstrap | Mint the first admin token with the single-use bootstrap secret. |
GET | /api/auth/whoami | The presented token’s own roles. |
POST | /api/auth/exchange | Exchange an OIDC JWT for a short-TTL token (oidc feature). |
Cluster
| Method | Path | Purpose |
|---|---|---|
POST | /api/cluster/join-token | Mint a single-use mesh join token. |
POST | /api/cluster/join | Admit a joining node presenting a join token. |
POST | /api/cluster/rotate-key | Rotate this node’s mesh key (make-before-break). |
POST | /api/cluster/revoke | Revoke a node from the mesh. |
See Manage cluster mesh certificates.
Certificates & cache
| Method | Path | Purpose |
|---|---|---|
GET | /api/certs | TLS certificate status. |
POST | /api/cache/invalidate | Invalidate cached responses. |
Operations
| Method | Path | Purpose |
|---|---|---|
GET/POST | /api/prune | Report / delete unreferenced deployments. |
POST | /api/scrub | Delete unreferenced blobs. |
GET | /api/metrics | Prometheus exposition (always available). |
GET/PUT | /api/authz/policy | Read / replace the RBAC policy. |
Compute
| Method | Path | Purpose |
|---|---|---|
GET | /api/compute | List compute workloads. |
GET/PUT/DELETE | /api/compute/:name | Manage one workload. |
Requires KVM on the serving host; the control-plane surface is uniform whether or not execution is available. See Run compute workloads.
Per-site observability
Present with the handlers feature.
| Method | Path | Purpose |
|---|---|---|
GET | /api/sites/:site/_boatramp/handlers | Per-handler operator stats. |
GET | /api/sites/:site/_boatramp/logs | Captured guest logs. |
GET | /api/sites/:site/_boatramp/logs/stream | Stream logs (SSE). |
POST | /api/sites/:site/_boatramp/dlq | Dead-letter-queue operations. |
Public (unauthenticated) endpoints
Never token-authenticated. Visitor access control (basic auth / IP rules / rate limit) is applied per-site inside the serving handlers.
| Method | Path | Purpose |
|---|---|---|
GET | /healthz | Liveness. |
GET | /readyz | Readiness. |
| any | / (host-routed) | Serve site content, selected by Host — see How a request reaches your site. |
| any | /_sites/<name>/* | Serve a site by name (admin/testing). /sites/<name>/* is a deprecated alias. |
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 site. A token carries one or more granted roles; a role expands to a set of rights. A request is allowed when a held right satisfies the right the request requires.
For issuing and verifying tokens, see Bootstrap authentication and Make a scoped CI deploy token; for the design, see Authentication & authorization.
Actions
| Action | Meaning |
|---|---|
read | Read and list (GET endpoints). |
write | Mutate configuration: site config, aliases, domain verification, cache. |
deploy | Ship content: create and activate deployments, upload blobs. |
admin | Full control of the resource. |
Only admin implies the others: a held admin right on a resource satisfies a
required read, write, deploy, or admin on that same resource. The other
three actions are independent. Implication is per-resource — admin on tokens
does not satisfy any right on site.
Resources
Only site is target-scoped (the target is a site name); the other five are
global.
| Resource | Scoped | Governs |
|---|---|---|
site | site | Per-site deployments, config, aliases, domain verification, per-site observability. |
blobs | global | Content-addressed blob uploads. |
tokens | global | API token management. |
certs | global | TLS certificate status. |
cache | global | Cache invalidation. |
system | global | Metrics, prune, scrub, site listing, cluster membership, authz policy. |
Default roles
The built-in policy defines five roles. A grant marked (site) binds to the role instance’s target; (any) is a global right.
| Role | Scoped | Grants |
|---|---|---|
admin | global | admin on every resource. |
publisher | site | read, write, deploy on site (site); deploy on blobs (any). |
deployer | site | read, deploy on site (site); deploy on blobs (any). No config write. |
viewer | site | read on site (site). |
operator | global | read on system (any); read on certs (any); write on cache (any). No site access. |
An unknown role name grants nothing — it is ignored, not an error.
Scoping
A granted role is written <role> (global) or <role>:<site> (bound to one
site). The suffix after the first : is the target site; an empty suffix parses
as global.
| Spec | Interpretation |
|---|---|
admin | Global admin. |
publisher:blog | publisher bound to site blog. |
viewer:docs | viewer bound to site docs. |
Granting a site-scoped role without a target (e.g. publisher with no
:site) drops its site rights — a global publisher grants only its blobs
right. Site matching is exact; a global (wildcard) grant covers every site.
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:blog --role viewer:docs may write blog, read 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.
| Method | Path | Required right |
|---|---|---|
POST | /api/auth/exchange | none (carries an IdP JWT) |
GET | /api/auth/whoami | none (any valid token) |
POST | /api/tokens/bootstrap | none (bootstrap secret) |
POST | /api/cluster/join | none (single-use join token) |
PUT | /api/blobs/<hash> | blobs · deploy |
GET | /api/sites | system · read |
POST | /api/sites/<site>/deployments | site · deploy |
GET | /api/sites/<site>/deployments[/<id>] | site · read |
POST | /api/sites/<site>/deployments/<id>/activate | site · deploy |
GET | /api/sites/<site>/config | site · read |
PUT | /api/sites/<site>/config | site · write |
PUT/DELETE | /api/sites/<site>/aliases/<name> | site · write |
POST/DELETE | /api/tokens[/<id>] | tokens · admin |
GET | /api/certs | certs · read |
POST | /api/cache/invalidate | cache · write |
GET | /api/metrics | system · read |
GET/POST | /api/prune, /api/scrub | system · admin |
| any | /api/authz/* | system · admin |
| any | other /api/* | system · admin (deny-safe) |
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 --auto --provider <name>.
Providers
--provider | Alias | Provider | Credential env vars |
|---|---|---|---|
manual | — | none (prints records) | — |
cloudflare | — | Cloudflare | CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN |
route53 | — | AWS Route 53 | ROUTE53_HOSTED_ZONE_ID + the standard AWS chain |
oci | — | Oracle Cloud DNS | OCI_REGION, OCI_ZONE, OCI_KEY_ID, OCI_PRIVATE_KEY_FILE |
digitalocean | do | DigitalOcean | DIGITALOCEAN_DOMAIN, DIGITALOCEAN_TOKEN |
hetzner | — | Hetzner DNS | HETZNER_ZONE_ID, HETZNER_ZONE, HETZNER_DNS_TOKEN |
ns1 | — | NS1 (IBM) | NS1_ZONE, NS1_API_KEY |
dnsimple | — | DNSimple | DNSIMPLE_ACCOUNT_ID, DNSIMPLE_ZONE, DNSIMPLE_TOKEN |
gcp-dns | gcp | Google Cloud DNS | GCP_DNS_PROJECT, GCP_DNS_ZONE, GCP_ACCESS_TOKEN |
azure-dns | azure | Azure DNS | AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, AZURE_DNS_ZONE, AZURE_ACCESS_TOKEN |
akamai | — | Akamai Edge DNS | AKAMAI_HOST, AKAMAI_CLIENT_TOKEN, AKAMAI_CLIENT_SECRET, AKAMAI_ACCESS_TOKEN, AKAMAI_ZONE |
Notes
manualprints the records to apply by hand and reads no credentials. It is the fallback for self-hosted authoritative servers (BIND, PowerDNS, Knot).gcp-dnsandazure-dnstake a short-lived OAuth2 access token inGCP_ACCESS_TOKEN/AZURE_ACCESS_TOKEN. Mint it withgcloud/az.route53readsROUTE53_HOSTED_ZONE_IDfor the zone and resolves credentials through the standard AWS provider chain (environment, shared config, instance role).
See also
Cargo features & platform support
boatramp is one binary of feature-gated crates. The default build is lean; the heavier capabilities are compiled in with cargo features. This page lists the cargo features and their default state, then which capabilities are Linux-only. To turn features on when compiling, see Build from source.
Cargo build features
The default set is fs and slatedb; every other feature is off unless named on
the cargo build command line. Some features imply others: http3 implies
tls, acme-dns implies tls, and cluster implies handlers and slatedb.
| Feature | Default | Enables |
|---|---|---|
fs | yes | Filesystem blob backend (--blobs fs). |
slatedb | yes | The default --kv slatedb: a durable transactional LSM over an object_store backend. |
s3 | no | S3 blob backend (--blobs s3). |
cloudflare-kv | no | Cloudflare KV metadata backend. |
tls | no | HTTPS: --tls custom (operator cert) and --tls acme (automatic certs). |
acme-dns | no | Wildcard TLS via ACME DNS-01 plus the dns subcommand (--tls acme-dns) and the pluggable DNS-provider clients. Implies tls. |
http3 | no | HTTP/3 (QUIC) serving alongside the TLS TCP listener. Implies tls. |
oidc | no | OIDC → token exchange: verify serve against an OIDC issuer’s JWKS. |
signer-aws | no | External token signer backed by AWS KMS. |
signer-gcp | no | External token signer backed by GCP KMS. |
signer-azure | no | External token signer backed by Azure Key Vault. |
signer-vault | no | External token signer backed by HashiCorp Vault. |
signer-pkcs11 | no | External token signer backed by a PKCS#11 HSM. |
compression | no | On-the-fly response compression, opt-in per site. |
bundler | no | The in-process JS/TS + CSS bundler for boatramp bundle. |
handlers | no | The wasmtime handler engine, component validation at sync, and the sql handler binding. |
cluster | no | Self-hosted Raft cluster mode. Implies handlers and slatedb. |
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.
# HTTPS, handlers, and wildcard preview certs.
cargo build --release -p boatramp --features tls,handlers,acme-dns
Platform support
The publish / serve / handler / TLS / cluster core is cross-platform. The compute execution backends differ:
| Platform | Compute backends |
|---|---|
Linux x86_64, aarch64 | microVM (needs /dev/kvm), native container, remote-docker |
| macOS, Windows | remote-docker only |
The microVM and native-container backends need /dev/kvm, namespaces, and the
jailer, so they are Linux-only; on macOS and Windows that code compiles to
no-ops and compute runs through the remote-docker backend against a Linux Docker
host.
See also
- Build from source — toolchain and selecting features at build time.
- Install boatramp — prebuilt archives and packages.
Metrics & access-log fields
boatramp exports Prometheus metrics and a structured access log from the same serving path. This page lists the exported metrics and the access-log fields. For how to scrape and read them, see Observe a running server.
The Prometheus exporter at /api/metrics is admin-scoped. The handler and
consumer metrics are present only when the binary is built with the handlers
feature.
Prometheus metrics
Exported at /api/metrics.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
boatramp_http_requests_total | counter | status_class, cache_result | Requests by status class (2xx / 3xx / …) and cache result. |
boatramp_http_response_bytes_total | counter | — | Total response body bytes streamed. |
boatramp_deployments_total | counter | — | Deployment manifests created. |
boatramp_activations_total | counter | — | Activations (live / alias pointer flips). |
boatramp_cert_renewals_total | counter | — | ACME certificate issues and renewals. |
With the handlers feature the exporter also renders per-(site, trigger, route) handler-invocation counters and per-consumer queue-depth and dead-letter
gauges.
Access-log fields
Every request is logged on the boatramp::access tracing target. Set
BOATRAMP_LOG_FORMAT=json for a machine-readable sink; verbosity follows
RUST_LOG (default boatramp=info).
| Field | Meaning |
|---|---|
method | HTTP request method. |
path | Request path. |
host | Request host. |
client_ip | Client IP address. |
status | Response status code. |
bytes | Response body bytes. |
encoding | Content encoding applied to the response. |
cache_result | Cache outcome for the request (see below). |
duration_ms | Time taken to serve the request, in milliseconds. |
cache_result values
| Value | Meaning |
|---|---|
full | Served fully from cache. |
partial | Partial-content (Range) response. |
not-modified | Conditional request answered 304. |
redirect | Answered with a redirect. |
error | Answered with an error. |
KV Keyspace
The authoritative map of every key boatramp writes, across its two backends.
Prefixes are distinct and slash-delimited so a list_prefix scan enumerates one
kind without matching another.
- Storage (fs / S3 / R2) — blob content.
- KV (SlateDB / memory / Cloudflare KV; or
RaftKvin cluster mode) — all control-plane metadata.
Storage (blob content)
| Key | Value |
|---|---|
<2>/<sha256> | raw file bytes, sharded by the first 2 hex chars of the hash (e.g. ab/abcdef…) |
Blobs are content-addressed and immutable: the key is the SHA-256. boatramp scrub re-hashes each to detect drift.
KV (control plane)
| Key | Value |
|---|---|
manifests/<id> | a deployment Manifest (file→hash map + DeployConfig); <id> is its content hash |
meta/<id> | DeployMeta (created-at, sizes, source/branch/author/message) |
current/<site> | the live deployment id for a site |
history/<site> | the site’s activation log |
alias/<site>/<name> | a named alias → deployment id |
site/<site> | mutable pointer → the hash of the site’s current SiteConfig |
siteconfig/<hash> | immutable content-addressed SiteConfig body (dedups across sites) |
domain/<host> | exact host → site (routing index) |
wildcard/<suffix> | wildcard suffix → site |
domainverify/<site>/<host> | a domain-ownership challenge |
authz/policy | the RBAC policy (roles → rights); absent ⇒ the built-in default |
authz/tokens/<id> | issued-token metadata (label, roles); the token is never stored |
authz/revoked/<id> | a revocation marker (presence ⇒ revoked) |
cert/<domain> | a stored cert (chain + key + expiry) — cluster-managed |
Messaging (handler wasi:messaging)
| Key prefix | Value |
|---|---|
mq/<topic>/<id> | a queued record |
mqp/<topic>/<id> | in-flight (claimed) marker |
mqdead/<topic>/<id> | a dead-lettered record |
Cluster Raft store (cluster mode only)
Each node’s durable local KV, distinct from the replicated control plane it serves:
| Key | Value |
|---|---|
raft/vote | the node’s current vote |
raft/committed, raft/purged | log progress markers |
raft/log/<index:020> | a Raft log entry |
raft/sm/last_applied, raft/sm/membership | applied-state metadata |
raft/sm/d/<key> | applied state-machine data (mirrors the control-plane keys) |
raft/snapshot | the latest snapshot |
Immutable vs mutable
Content-addressed keys (manifests/<id>, siteconfig/<hash>, blobs) are
immutable — cached forever, never in the cache-coherence
feed. Only mutable pointers/config (current/, site/, domain/, tokens/,
cert/) need invalidation. Coordination state (ratelimit/, mqp/) is never
cached.
Errors & exit codes
Exit codes
The boatramp CLI uses the two standard shell exit codes:
| Code | Meaning |
|---|---|
0 | Success. |
1 | Any error. |
On failure the CLI prints the error and its cause chain to stderr, then exits
1:
error: failed to publish site "blog"
caused by: server returned 403 Forbidden
caused by: token lacks required right site:blog · deploy
The top line is the command-level error; each caused by: is one link deeper in
the underlying cause, so the root cause is the last line. Scripts should branch on
the exit code (0 vs non-zero) rather than parse the message text.
(The one place a different code appears is the internal container/VMM sandbox worker, which propagates the guest’s exit status — not a surface a user invokes.)
API status codes
When the CLI talks to a server, an HTTP error is surfaced in the cause chain above. The control-plane API uses conventional statuses:
| Status | Meaning | Common cause |
|---|---|---|
400 | Bad request | Malformed body, or an invalid authz policy. |
401 | Unauthenticated | Missing, malformed, expired, or revoked token. |
403 | Forbidden | Valid token without the required right. |
404 | Not found | Unknown site, deployment, or alias. |
409 | Conflict | State precondition failed (e.g. activating a nonexistent deployment). |
413 | Payload too large | Upload exceeds BOATRAMP_MAX_UPLOAD_BYTES. |
429 | Too many requests | Rate limit or upload-concurrency cap reached. |
503 | Unavailable | Upload slots exhausted, or the node is not ready. |
A non-2xx response carries a JSON { "error": "..." } body, which becomes the
deepest caused by: line.
Validation errors
boatramp validate (and sync, which validates first) reports config problems
against project.cfg before anything is published — a bad route pattern, an
unknown handler import, an unparsable cron schedule, or a credential-shaped value
in a handler env. These fail at deploy time, not request time:
error: project.cfg: handler /api env var "TOKEN" looks like a secret; move it to
[handlers].secrets as a reference to a host env var
See the routing schema for the fields these checks cover.
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.
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.
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.
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
boatramp is a Rust workspace. The default build stays lean; heavier capabilities (TLS, ACME DNS-01, clustering, handlers, OIDC, compression, HTTP/3, the bundler) are behind cargo features.
Building & testing
cargo build # lean default
cargo test --workspace # the full suite
cargo clippy --workspace --all-targets -- -D warnings
cargo deny check # advisories / bans / licenses / sources
When you touch a feature-gated area, run clippy with that feature too — e.g.
cargo clippy -p boatramp-server --features handlers,oidc,compression --all-targets -- -D warnings. The pre-commit hooks run clippy, rustfmt, taplo,
and typos.
Principles
- Streaming-first. No byte path may buffer a whole file in memory.
- One UX across deploy targets. Environment differences live behind the
Storage/KvStore/Messagingtrait seams, never in the commands, flags, or config. - Complete implementations. Prefer real, validated code over stubs.
- Lean default build. New heavy dependencies go behind a feature.
- 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.