Standalone binaries
Run Sproutboat without a platform: one executable with its bindings compiled in.
sproutboat build --standalone emits one executable with its bindings, SQLite and TLS compiled in. Copy it to a Linux x86-64 machine and run it. The target needs no broker, Node, Bun or container.
npx sproutboat build --standalone # a few MB
PORT=3000 ./dist/hellopnpm sproutboat build --standalone # a few MB
PORT=3000 ./dist/hellobunx sproutboat build --standalone # a few MB
PORT=3000 ./dist/helloyarn sproutboat build --standalone # a few MB
PORT=3000 ./dist/helloThis suits a Raspberry Pi-class home server, a single VM, or any setup where you want to own the process directly. The same handler deploys to a platform later with sproutboat deploy, unchanged.
Add --target host to build for the machine you are on instead, to run it locally.
Configuration
A standalone binary reads no command-line arguments. Everything arrives through the environment:
| Variable | Purpose |
|---|---|
PORT |
Port to listen on. Default 8080. |
SB_DATA_DIR or SPROUTBOAT_DATA |
Where state lives. Default ./<name>.data. |
SB_TRUSTED_PROXIES |
Comma-separated reverse-proxy CIDRs or IPs. When the peer is one of them, request.cf.clientIp resolves from X-Forwarded-For (right-most untrusted entry); otherwise the header is ignored. |
SB_CA_BUNDLE |
A PEM file of extra certificate authorities, trusted on top of the compiled-in Mozilla roots. |
SB_REQUEST_BODY_MAX |
Largest inbound request body, in bytes. Default 1 MiB. |
SB_FETCH_MAX_BYTES |
Largest outbound fetch() response body, in bytes. Default 32 MiB. |
SB_LOG |
Set to json to write one JSON line to stderr per request and per uncaught error. Off by default. See Logs. |
SB_EGRESS_ALLOW |
Private addresses fetch() may reach, such as a database on the same host: * or a comma-separated list of exact addresses. See Outbound fetch. |
| (secrets) | Each secret is read from the environment variable of the same name. Started directly, a missing one does not stop the binary; reading it throws. sproutboat run checks them first and fails if one is missing. |
Launching with sproutboat run
sproutboat run [dir] [--binary <file>] [--env-file <file>]... [--port <n>] [--data-dir <dir>] [--require <NAME,...>] [--check]
run starts the binary with the settings it takes at launch, none of them compiled in, so the identical file moves between machines:
sproutboat run --env-file dev.env --port 8080 --data-dir ./dev-data
sproutboat run --env-file /etc/notes/prod.env --port 80 --data-dir /var/lib/notes
The binary is --binary, or dist/<name> in the project. The declared secrets are the secrets in sproutboat.jsonc plus any --require names; without a project, --binary and --require are enough.
Precedence, highest first, for every variable:
--port(setsPORT) and--data-dir(setsSB_DATA_DIR).- The
--env-filefiles. Repeat the flag and a later file overrides an earlier one. - The environment
runwas started from, which is how systemd or a container hands a production binary its settings.
A stale export API_KEY=... in your shell therefore loses to the file you named, and --port wins over a PORT line in a file. run reads only the files you name: it never picks up .dev.vars or .env by itself.
A declared secret that is unset or empty fails the launch before the binary starts, naming the secrets and never their values. This is the counterpart of wrangler deploy failing on a missing required secret; sproutboat dev only warns. --check does all the validation and prints a summary without starting the binary. Nothing is written to disk, and the summary shows each secret with the layer it came from (API_KEY (env file)), not its value. run stays in the foreground, passes SIGINT, SIGTERM and SIGHUP to the binary and exits with its status.
Env file syntax
dev and run read dotenv syntax:
# a comment line
API_KEY=abc123
export DB_URL = postgres://user:pass@host/db
GREETING='literal $text, \n and # kept'
MESSAGE="newline \n expands in double quotes"
NOTE=plain value # a comment after an unquoted value
PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...
-----END PRIVATE KEY-----"
NAME=value, optionallyexport NAME=valueorNAME: value. Names are letters, digits,_,.and-.- Single quotes and backticks are literal. Double quotes turn
\nand\rinto newlines. A quoted value may span lines. There is no$VARinterpolation. - An unquoted value ends at a
#and is trimmed, so quote a value that contains one. - The last of a repeated name wins.
- A line that is not an assignment, or a quote that never closes, is skipped with a warning such as
dev.env:7: skipped, not a NAME=value assignment. Warnings never repeat the text of a line.
sproutboat dev follows Wrangler
sproutboat dev takes local secrets from files by the same rules as wrangler dev:
.dev.varsor the.envfiles, never both. If.dev.varsexists next tosproutboat.jsonc, the.envfiles are not loaded, and a warning names them. With--env <name>,.dev.vars.<name>replaces.dev.varswhen it exists.- The
.envfiles layer, most specific first:.env.<name>.local,.env.local,.env.<name>,.env. Without--env, just.env.localand.env.CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=falseturns this off. --env-file <file>replaces that default set. Repeat it and a later file overrides an earlier one. A named file that does not exist is an error.- The process environment is not passed to the app. With
CLOUDFLARE_INCLUDE_PROCESS_ENV=true, and only when no.dev.varsis in use, a declared secret takes its value from the environment; the files win over it. - A declared secret with no value is a warning, and
devstill starts. A handler that reads it throws, as in production.
--env only selects local files; named configuration environments are a separate feature. Keep env files out of version control. On a server without the CLI, systemd’s EnvironmentFile= does the same job; its syntax is simpler than dotenv, so keep that file to plain NAME=value lines.
Logs
By default a binary writes nothing per request. Start it with SB_LOG=json and stderr carries one JSON line per request and one per uncaught error, which sproutboat tail --file follows and filters:
SB_LOG=json ./notes 2>> /var/log/notes.jsonl
sproutboat tail --file /var/log/notes.jsonl --status 5xx
Under systemd, set Environment=SB_LOG=json and StandardError=append:/var/log/notes.jsonl, or pipe the journal: journalctl -u notes -f -o cat | sproutboat tail --file -.
A request record is written once the response is built, and outcome is exception when the handler threw:
{"type":"request","v":1,"id":"1762600000-17","timestamp":1762600012345,"method":"GET","path":"/notes","status":200,"durationMs":3,"outcome":"ok"}
An error record is written for every uncaught error: a fetch handler, scheduled, queue, an alarm, a WebSocket handler, a rejected promise (promise) or a ctx.waitUntil task (waitUntil):
{"type":"error","v":1,"id":"1762600000-18","timestamp":1762600012399,"source":"fetch","name":"TypeError","message":"Cannot read properties of undefined","stack":"..."}
An error raised while answering a request has that request’s id, so both lines can be pulled out with jq 'select(.id=="1762600000-18")'. Errors from work that belongs to no request (a cron, a queue batch, an alarm, an unawaited promise) have "id": null. timestamp is milliseconds since the epoch.
Left out: request and response bodies, headers (cookies and Authorization included), the query string and the client address. A declared secret’s value is replaced with [redacted] wherever it appears in an error’s message or stack; other text your code puts in an error message is written as it is, so keep credentials out of messages.
Output is bounded. Paths are cut at 256 characters, messages at 1000 and stacks at 2000, and at most 200 lines a second are written. Past that, records are counted and the next second starts with {"type":"dropped","v":1,"timestamp":...,"count":40,"reason":"rate"}. The binary only writes to stderr, so rotating the log is up to you (logrotate with copytruncate, or journald).
Following a log
sproutboat tail --file <log|-> [--lines <n>] [--no-follow] [--format pretty|json] \
[--status ok|error|<code>|5xx] [--method GET,POST] [--search <text>]
It prints the last 10 matching records (--lines, 0 for none), then new ones as they are written. --no-follow prints that snapshot and exits, and --file - reads standard input until it ends. Ctrl-C and SIGTERM end it with exit 0. It needs no login and no network.
--format jsonwrites one record per line to stdout and only that: notices go to stderr, so the output stays parseable. Only the fields above are written, whatever the log holds.--status,--methodand--searchselect requests. An error is shown when its request is, and an error with no request is always shown.okis a success below 400;erroris a 5xx or a thrown handler.- A missing file is waited for. A file replaced by rotation, or truncated in place, is read again from its start. A line over 64 KiB is skipped and counted. Lines that are not records (a startup banner, other text) are skipped.
State on disk
<name>.data/
store.sqlite KV, R2 metadata, queues, Durable Object storage and alarms, Analytics Engine, rate limits, cache
r2-blobs/ R2 object bytes, one file per object
d1/<binding>.sqlite one file per D1 database
backups/ D1 backup() snapshots
Each D1 database is its own file because it runs your SQL: a CREATE TABLE kv (...) in a handler would otherwise collide with Sproutboat’s own tables. sproutboat dev uses the same layout.
Differences from a platform deployment
- Plain HTTP inbound. It serves HTTP on
$PORT. Put Caddy, nginx or a tunnel in front for HTTPS. - Verified TLS outbound.
fetch("https://…")verifies against the compiled-in Mozilla roots plusSB_CA_BUNDLE. - Internal triggers. Cron, queue batches and alarms run on timers inside the process. Trigger requests from the network are refused.
- No service bindings. They route through the platform’s edge.
- Assets compile in, up to 8 MB in total. For more, store files in R2 or put a web server in front.
- One thread. The event loop overlaps connection I/O, but the handler and every binding call run one at a time, so throughput per process is about
1 / handler time. For more, run several copies with the same$PORTand$SB_DATA_DIR: they share the port throughSO_REUSEPORT, and SQLite’s WAL mode handles the shared files.