CLI
Every command, subcommand, argument and flag of the scafo binary.
One executable is both the node daemon and the operator commands. Top-level commands, in the order scafo --help prints them:
sail, health, remote, operator, fleet, app, deploy, deployment, trust, metrics, traces, wg.
Two hidden aliases remain from a rename: scafo up → scafo sail, and scafo keygen → scafo operator keygen. Both still work, print a deprecation line before running, are hidden from help, and are removed at launch.
#Exit behaviour
Every command wraps its work in a single failure reporter.
| Outcome | stderr | Exit code |
|---|---|---|
| Success | command's own output on stdout | 0 |
| A scafo failure | scafo: <title>[: <detail>] [urn:scafo:<code>] | 1 |
| Anything else | scafo: unexpected failure: <message chain, up to 4 causes> | 1 |
The bracketed urn:scafo:<code> is the stable contract; see /reference/errors. A failure answered by a node is re-rendered from the node's own problem document, so the code the node chose is the code printed.
#Shared flag groups
Referenced by name in each command below.
#Bucket flags
Where the registry lives, taken exactly as scafo sail takes them. All strings.
| Flag | Description |
|---|---|
--config | Path to scafo.config.jsonc |
--endpoint | S3-compatible endpoint URL |
--region | Signing region of the bucket |
--bucket | Bucket holding the registry |
--prefix | Key prefix within the bucket |
--access-key | Bucket access key id |
--secret-key | Bucket secret access key |
Each also reads from the environment and the config file; see /config for precedence and variable names.
#Remote flags
Which installation an invocation reaches, and who is speaking. All strings.
| Flag | Description |
|---|---|
--config | Path to scafo.config.jsonc, read for its remote pin |
--remote | Remote to act on, out of this operator's home |
--node | Node to reach, instead of the remote's first endpoint |
--key | Operator key to sign with: a key file from scafo operator keygen, a portable file from scafo remote export, or an inline base64: value of one |
Resolution order: the key comes from --key, then SCAFO_OPERATOR_KEY, then the remote. The endpoint comes from --node, then whatever a portable key carried, then the remote. The remote comes from --remote, then SCAFO_REMOTE, then the config file's remote pin, then the only remote registered; a home holding several with nothing to choose between them prompts on a TTY and is refused (invalid-input) otherwise.
#--json
Boolean. Prints the records as JSON instead of a table. Accepted by fleet nodes, app list, app show, app domain add, app domain remove, app domain list, metrics and traces.
#Naming an application
Every command that acts on one application addresses it the same way, with two flags resolved client-side before anything is contacted:
| Flag | Type | Description |
|---|---|---|
--name | string | The application to act on |
--env | string | Address the application <name>-<env> instead |
--env is a naming convenience and nothing else: the node never sees it, and --name shop --env preview addresses the application shop-preview exactly as --name shop-preview would. Both halves must be application names in their own right, and so must the joined result; a join that is no name — too long for a DNS label, above all — is refused with invalid-slug naming both parts.
An application name is 1–63 lowercase alphanumeric characters, optionally separated by single hyphens.
--name is required by app (every subcommand), trust (every subcommand) and deployment prune. It is optional on deploy, where the project directory's config supplies it, and on metrics and traces, where leaving it out means every application.
#scafo sail
scafo sail [flags]Join this machine to the fleet and serve the node API. Talks to the bucket (registers the node, reads and writes the registry) and binds the node API listener. Prints scafo <version> listening on <url> as node "<id>" once serving.
Every flag is the kebab-case name of a configuration setting, plus --config. Flags win over environment variables, which win over the config file. Booleans support --no- negation; a boolean nobody wrote leaves the environment and the file to decide.
| Flag | Type | Description |
|---|---|---|
--config | string | Path to scafo.config.jsonc |
--endpoint | string | S3-compatible endpoint URL |
--region | string | Signing region of the bucket |
--bucket | string | Bucket holding the registry |
--prefix | string | Key prefix within the bucket |
--access-key | string | Bucket access key id |
--secret-key | string | Bucket secret access key |
--node-id | string | Id this node registers under |
--state-dir | string | Directory this node keeps its identity in |
--node-labels | string | What this node is, for applications to select on; one pair per flag (--node-labels region=eu-west-1 --node-labels tier=fast) |
--listen | string | host:port the node API binds to |
--advertise | string | host:port other nodes reach this one at |
--transport | string | What carries peer traffic (wireguard, none) |
--wg-address | string | This node's tunnel address with its prefix length (10.83.0.1/24) |
--wg-endpoint | string | host:port peers send this node's tunnel traffic to |
--wg-port | string | UDP port the tunnel interface listens on (51820) |
--platform-domains | string | Apex domain to serve; repeat the flag for several |
--ingress-token | string | Token admitted on /v1/ingress and /v1/storage, instead of one this node mints. Repeat the flag to admit the previous one as well while a rotation is in flight; the first is written to the state directory's ingress-token file. Restart the daemon before reloading the ingress |
--celld-bin | string | Executable that runs one application |
--celld-env | string | Upstream Celld variable for every runtime process; one NAME=value pair per flag, and a value written here may hold anything, commas included |
--port-range-start | string | First port runtime processes use (24000) |
--port-range-end | string | Last port runtime processes use; with --network host these are the host's ports, so fit the range to the machine (24998) |
--port-quarantine-ms | string | How long a port released on this node is kept from a different application, in milliseconds, so nothing still routing there reaches another application (600000) |
--bind-host | string | Address runtime processes bind their listeners to |
--registry-resync-ms | string | How often this node re-reads the whole registry regardless of the change hint, in milliseconds: the bound on a hint that never arrived (60000) |
--registry-node-refresh-ms | string | How often this node re-reads the fleet's node records, in milliseconds; liveness is the age of a heartbeat and no write announces a death (30000) |
--coordinator-lease-ttl-ms | string | Lifetime written into the coordinator lease, in milliseconds; it is renewed at a third of it (45000) |
--coordinator-reap-interval-ms | string | Time between two reap passes while this node coordinates (600000) |
--artifact-bytes | string | Maximum compressed and expanded bytes per deploy artifact |
--archive-entries | string | Maximum entries per deploy archive |
--active-tasks | string | Maximum active deploy tasks on this node |
--application-active-tasks | string | Maximum active deploy tasks for one application |
--queued-tasks | string | Maximum receiving and queued deploys |
--application-queued-tasks | string | Maximum receiving and queued deploys for one application |
--staged-bytes | string | Maximum staged deploy bytes on this node |
--application-staged-bytes | string | Maximum staged deploy bytes for one application |
--receive-timeout-ms | string | Artifact receive deadline in milliseconds |
--queue-timeout-ms | string | Queued deploy deadline in milliseconds |
--task-timeout-ms | string | Active deploy deadline in milliseconds |
--output-bytes | string | Subprocess output tail retained in memory |
--observability / --no-observability | boolean | Report what this node does: emit its own events, and give every runtime process CELLD_OTEL=1 unless runtime.celldEnv already states it |
--ingress-access-log | string | File the ingress writes its JSON access log to; the node follows it and counts requests, bytes, statuses and latencies per application. Needs --observability |
--api-public / --no-api-public | boolean | Serve the fleet's API at each platform domain's own apex, so any node is reachable at one well-known name (default on; --no-api-public leaves the apex unrouted and without a certificate). /v1/storage and /v1/ingress/routes are never reachable there |
--json-bytes | string | Maximum bytes accepted by a JSON API control request |
--traefik-entrypoints | string | Traefik entryPoint the published routers attach to; repeat the flag for several (--traefik-entrypoints web --traefik-entrypoints websecure) |
#scafo health
scafo health [bucket flags] [--listen] [--transport] [--wg-address]Ask this machine's node whether it is serving. Talks to the local node API — GET /.well-known/scafo/health, with a 2-second deadline. It is what the image's HEALTHCHECK runs.
| Flag | Type | Description |
|---|---|---|
| bucket flags | string | As above; the loader asks for them |
--listen | string | host:port the node API binds to |
--transport | string | What carries peer traffic (wireguard, none) |
--wg-address | string | This node's tunnel address with its prefix length (10.83.0.1/24) |
It resolves node.listen exactly as scafo sail does, from the environment and the config file, so the probe follows the listener. A setting given to the daemon only on its command line must be repeated here.
Failures are not-found: Node is not answering (nothing answered within the deadline) or Node is not healthy (something answered, but not the health document this node serves). Both name the URL that was asked.
#scafo remote
Name the installations you can reach, and hold the key that speaks to each. Everything here acts on the operator home (a local directory), except --register, which writes to the bucket.
Warning
--endpoint here is a node daemon URL, not an S3 endpoint. The bucket for --register is configured through the config file and the environment instead.
#scafo remote add
scafo remote add [name] --endpoint <url> [flags]Register a remote, with a key of its own.
| Argument | Type | Required | Description |
|---|---|---|---|
name | positional | no | Name to reach this remote by |
--endpoint | string | — | Base URL of one of its nodes; repeat the flag for several |
--key | string | no | Reuse a key already in this home, instead of minting a fresh one |
--from | string | no | Import a portable file: a path, or an inline base64: value |
--register | boolean | no | Record the key's public half in the registry, bucket-side (admins only) |
--role | string | no | Tier to register at: admin (the first key is always one, and this path admits no other — an operator-tier key needs an application scope, which only the wire path can give it) |
--config | string | no | Path to scafo.config.jsonc, for --register |
Without --register, a freshly minted key is only local: register it later, or have an admin admit it with scafo operator enroll.
#scafo remote list
scafo remote listPrint the remotes this operator can reach. No arguments.
#scafo remote remove
scafo remote remove <name>Unregister a remote, and its key if nothing shares it. name is a required positional. Nothing is said to the installation itself: the key it holds stays accepted there until it is removed from the registry.
#scafo remote export
scafo remote export <name> [--out <file>]Write a remote out as one portable, self-contained file.
| Argument | Type | Required | Description |
|---|---|---|---|
name | positional | yes | Remote to write out |
--out | string | no | File to write it to, readable by its owner alone |
#scafo operator
The people: every human here is an operator, and admin is a role an operator holds.
#scafo operator keygen
scafo operator keygen [bucket flags] [--out <file>] [--id <op-...>] [--register] [--role <tier>]Write an operator key file for the node API. To set an operator up, use scafo remote add instead. Talks to nothing unless --register is given, which writes the public half straight to the bucket — the bootstrap path, needing no daemon.
| Flag | Type | Description |
|---|---|---|
| bucket flags | string | As above, for --register |
--out | string | File to write the key pair to (scafo-operator.json) |
--id | string | Id to register the key under (op-<hex>) |
--register | boolean | Record the public half in the registry |
--role | string | Tier to register at: admin (the first key is always one, and this path admits no other) |
#scafo operator show
scafo operator show [--config] [--remote] [--key]Print this operator's public half, to send to an admin. Local only. The document goes to stdout and the explanatory line to stderr, so scafo operator show > me.json produces a file an admin can enrol as it stands.
| Flag | Type | Description |
|---|---|---|
--config | string | Path to scafo.config.jsonc, read for its remote pin |
--remote | string | Remote whose key to show, out of this operator's home |
--key | string | Key to show instead: a key file, a portable file, or an inline base64: value |
#scafo operator enroll
scafo operator enroll <pubkey> [--remote <name>] [--role] [--apps] [--node] [bucket flags]Admit somebody else's public key to a fleet. Two paths, never chosen by default: the wire path (POST /v1/operators on a node, authorized by an admin key) and the bucket ceremony (authorized by the bucket credential, for an installation being seeded or one that has locked every operator out).
| Argument | Type | Required | Description |
|---|---|---|---|
pubkey | positional | yes | Public document from scafo operator show: a path, an inline base64: value, or the document itself |
--remote | string | no | Remote whose node admits the key, admin-authed |
--role | string | no | Tier to admit it at: admin or operator |
--apps | string | no | Application the key may name: an exact name, a prefix glob such as shop-*, or * alone for every application. Repeat the flag for several. Required for an operator-tier key (wire path only) |
--node | string | no | Node to reach, instead of the remote's first endpoint |
| bucket flags | string | no | Selects the bucket ceremony when any of them is given |
The bucket ceremony seeds and recovers admins: it is never scoped and never at the operator tier.
#scafo operator rotate
scafo operator rotate [--config] [--remote] [--node] [--keep-old] [--purge-old]Replace the key a remote signs with, proving the new one before dropping the old. Talks to the node API.
| Flag | Type | Description |
|---|---|---|
--config | string | Path to scafo.config.jsonc, read for its remote pin |
--remote | string | Remote to act on, out of this operator's home |
--node | string | Node to reach, instead of the remote's first endpoint |
--keep-old | boolean | Stop after the flip, leaving the old key trusted as well |
--purge-old | boolean | Delete the old key file once it is untrusted |
#scafo operator revoke
scafo operator revoke --id <id> [--config] [--remote] [--node]Stop a fleet trusting a key that is not your own. Talks to the node API.
| Flag | Type | Required | Description |
|---|---|---|---|
--id | string | yes | Key to stop trusting; not your own |
--config, --remote, --node | string | no | As for rotate |
#scafo operator list
scafo operator list [--config] [--remote] [--node]Print the operator keys a fleet trusts, and their tiers. Talks to the node API. An admin sees every key; an operator sees only its own rotation lineage.
#scafo fleet
The machines of one installation: what is out there, and what it is doing.
#scafo fleet nodes
scafo fleet nodes [bucket flags] [--json]List the machines in this fleet, and which are answering. Reads the bucket directly; no node has to be up. Every row is judged against one now, so nodes never appear to drift apart. Columns: NODE, STATE, SEEN, API, CELLD, SCAFO, LABELS; above the table, one line per binary counts nodes per version, newest first, and — while one stands — a line naming the deploy freeze, since when, by whom and why. --json carries the same rows, with versions: { scafo, celld } where the record has them, and no freeze line.
#scafo fleet freeze
scafo fleet freeze [remote flags] [--reason <line>]Stop every deploy of this installation, leaving serving alone. Admin only. Talks to a node, on the authority of an operator key: freezing is an authorization the bucket cannot enforce, so there is no bucket path here. Without a node the refusal is invalid-input, No node to ask.
| Flag | Type | Description |
|---|---|---|
| remote flags | string | As above |
--reason | string | Why, in one line, printed to everyone whose deploy is refused. At most 200 characters, no newline or control character |
While it stands, every node refuses deploy intake with deploys-frozen (409) before anything is created. Serving, reconciliation, retirement and scafo app delete carry on untouched.
Freezing twice is one freeze: the first admin's record — stamp and reason — is the one that stands, and the command says so.
scafo: deploys are frozen since 2026-09-18T09:12:04.115Z by "op-9f3c": moving every node to a new Celld release — every deploy is refused with deploys-frozen until "scafo fleet unfreeze"
scafo: deploys were already frozen since 2026-09-18T09:12:04.115Z by "op-9f3c" — every deploy is refused with deploys-frozen until "scafo fleet unfreeze"#scafo fleet unfreeze
scafo fleet unfreeze [remote flags]Take deploys again after a freeze. Admin only, and reaches the installation the same way.
scafo: deploys are no longer frozen; nodes accept them again
scafo: deploys were not frozen#scafo app
The things this installation hosts: list, inspect, place, retire and delete them. There is no create subcommand: an application comes into being on the first deploy that names it, and every verb here refuses a missing one with not-found.
Every subcommand takes the remote flags and names its target with --name and --env.
| Command | Extra arguments | Reaches the installation | Description |
|---|---|---|---|
scafo app list | --json | node only | List the applications this key reaches, and which of them are blocked |
scafo app show | --json | node only | Show one application: its state, placement and labels |
scafo app block | — | node only | Stop serving and deploying an application, reversibly (admin only) |
scafo app unblock | — | node only | Put a blocked application back into service (admin only) |
scafo app label | pairs trailing positionals, optional | node only | Replace an application's labels, which scafo stores and never reads (admin only) |
scafo app retire | — | node, else bucket flags | Stop serving an application, reversibly |
scafo app unretire | — | node, else bucket flags | Put a retired application back into service |
scafo app selector | --set <key=value> (repeat the flag for several), --clear | node, else bucket flags | Choose which nodes may serve an application |
scafo app replicas | --count (required) | node, else bucket flags | Choose how many nodes serve an application: all, or a whole number of at least 1 |
scafo app domain add | host positional (required), --wildcard, --json | node, else bucket flags | Claim a host for an application; the first claim of the installation wins |
scafo app domain remove | host positional (required), --json | node, else bucket flags | Drop a host this application holds |
scafo app domain list | --json | node, else bucket flags | The hosts claimed for an application |
scafo app delete | --force | node, else bucket flags | Delete a retired application; --force skips the retirement and grace requirements |
scafo app lease show | --node, --json | bucket only | Celld's own node leases of an application, as LIST, HEAD and GET each see them (internal tooling, unstable) |
scafo app lease drop | --node (required), --force, --yes, --dry-run, --json | bucket only | Delete one Celld node lease the store will not read (internal tooling, unstable) |
list, show, block, unblock and label are operator-key acts and have no bucket path: without a node the refusal is invalid-input, No node to ask. label replaces every label the application carries; passing no pairs clears them. A non-admin's view of an application omits the fleet-owned labels.
The rest also take the bucket flags and write the registry directly when no node is named. selector requires exactly one of --set and --clear; both, or neither, is invalid-input. delete prints the count and then every store key it removed, one per line — the record, its trust policy, its deployments, its deploys, its placement, its domain claims and the whole fleets/<name>/ prefix.
#scafo app domain
scafo app domain add <host> --name <name> [--env <name>] [--wildcard] [--json]
scafo app domain remove <host> --name <name> [--env <name>] [--json]
scafo app domain list --name <name> [--env <name>] [--json]The custom hosts an application answers on, beside its platform host. Over a node, both writing subcommands are POST and DELETE /v1/applications/:name/domains/:host, which answer to an operator token alone — a workload token never reaches them.
| Argument | Type | Required | Description |
|---|---|---|---|
host | positional | yes, on add and remove | The host itself, fully qualified: shop.example |
--wildcard | boolean | no | add only. Claim every name one label under the host too: <env>.<host> answers from <name>-<env>, and any other subdomain from the application itself |
--json | boolean | no | Print what was done as JSON instead of a summary |
The host is checked client-side before anything is contacted: it is lowercased, and anything that is no host to claim is invalid-input naming the rule it broke — a scheme, a path, a port, a *, a trailing dot, an empty or over-long label, a label outside letters, digits and inner hyphens, a name of one label only, or a last label of digits alone. A host equal to or under one of the installation's platform domains is invalid-input too, with the domain named. An application holds at most 64 of them.
A host another application holds is reference-held (409), and the detail names the holder only when the caller's own scope reaches it. Releasing a host this application does not hold is not-found, in the same words as one nobody claimed. Claiming for a blocked application is application-blocked; releasing stays open whatever state it is in.
--wildcard additionally requires a key whose scope reaches <name>-*, because the claim also decides what <name>-<env> answers on; a key that does not is auth-scope. Re-running add without the flag never takes the subdomains away, and remove drops the whole claim.
| JSON keys | On |
|---|---|
application, host, platformHosts | add and remove |
dns: ok | pending | unknown | add |
wildcardDns: same values | add, when the claim covers its subdomains |
application, domains, wildcardDomains | list |
After a successful add, the CLI resolves the host and the application's platform hosts and says whether DNS already points here. It is never a refusal: the claim is already written and the command exits 0 whatever the answer. A host that resolves here is an acknowledgement on stdout; anything else is one scafo: warning: … line on stderr, naming a platform host to point a CNAME at. With --wildcard it asks a second time, under a label nothing is ever deployed at, and warns about what the ingress still needs for names it cannot approve a certificate for. SCAFO_SILENCE_WARNINGS=1 silences all of it.
list prints one host per line, sorted, each marked (+subdomains) when its claim covers them; scafo app show marks them the same way.
Warning
Deleting through the bucket is lease-gated, so the CLI takes the coordinator lease for the length of the deletion and the installation briefly has no coordinator. A live coordinator holding the lease refuses that path with lease-lost, saying to ask the installation with --node instead.
#scafo app lease
scafo app lease show [bucket flags] --name <name> [--env <name>] [--node <id>] [--json]
scafo app lease drop [bucket flags] --name <name> [--env <name>] --node <id> [--force] [--yes] [--dry-run] [--json]Celld's own node leases of one application: look at them, and delete one the store will not read. Writes the bucket directly — fleets/<name>/nodes/<node>.json is a record Celld writes on its own schedule, no node holds this authority, and no node route offers it.
Warning
Internal tooling, unstable. This command exists for one failure and will go the moment Celld handles it itself; it is subject to change with every Celld release and with whatever upstream decides about leases. It warns on every run — scafo: warning: "scafo app lease" is subject to change: it reads and deletes inside Celld's own keyspace — the node lease a process must read or create before it has any authority — and it will be removed once Celld recovers such a lease itself; it runs only against the Celld releases it was verified against (0.5.0, 0.5.1, 0.6.0, 0.6.1, 0.6.2) — before anything is read. SCAFO_SILENCE_WARNINGS=1 silences it, as every other CLI warning.
A Celld process must read or create its node lease before it has any authority, and Scafo names that process after the node — the same name on every start, deliberately, so an acknowledged write survives a restart. So when a store holds a lease key it will list and HEAD but never GET, every restart of that node's process wedges on the same key, forever; the process's own log says refusing to install a lease over an unrecovered predecessor log. No amount of restarting is a way out, and the only thing that is, is deleting that one object. Scafo never does that on its own: this command is the operator's hands on that single deletion.
| Flag | Type | Required | Default | Description |
|---|---|---|---|---|
| bucket flags | string | no | — | As above |
--name | string | yes | — | Application whose Celld fleet holds the leases |
--env | string | no | — | Address the application <name>-<env> instead |
--node | string | drop only | every lease | The Celld node the lease is filed under — the Scafo node id. 1-128 ASCII letters, digits, underscores, hyphens or dots; anything else is invalid-slug |
--force | boolean | no | off | drop only. Delete a lease that is still held, which a running process may own |
--yes | boolean | no | off | drop only. Delete without asking; required when the input is not a terminal |
--dry-run | boolean | no | off | drop only. Print the reading and what would go, and delete nothing |
--json | boolean | no | off | Print the readings, or what was done, as JSON instead of a report |
#What a reading is
Every lease is judged on three reads laid side by side — what a listing of the fleet's nodes/ prefix says about the key, what a HEAD of it says, and what a GET of it says — and none of them may fail the reading: a store that raises on the very object being diagnosed is describing the problem, and that answer is printed as evidence.
| Verdict | The three reads say |
|---|---|
held | The body reads as a node lease (Celld's own two required fields, node and expires_ms) and its expiry is still ahead of wall time: a process may be holding it |
expired | A node lease whose expiry has passed |
unreadable | The listing names it or a HEAD answers, and a GET says it is not there or fails — the shape of the corruption this command exists for; or a body was read whole and is no node lease |
absent | Nothing is there: the listing, a HEAD and a GET all say so. Only a named --node ever produces one |
A lease whose body names a different node than the key is filed under is reported as such: Celld's own reaper collects a lease only when the two agree, so that record is one nothing upstream will ever tidy away.
show prints one block per lease — the verdict and the key, then the three reads with the size, ETag and modification time each reported, then the sentence the verdict rests on — and a summary counting the verdicts. When one is unreadable, the summary ends with the exact drop command for the first of them. An application with no lease at all is one whose fleet never ran, or whose key prefix is not the one it was published under; the line says so.
#Dropping one
drop prints the reading first, because a lease is the one record whose deletion must be weighed against a process that may still be running, then asks:
Delete the unreadable node lease "fleets/shop/nodes/vps-a.json"? [y/N]Only y or yes, in any casing, is consent. --yes answers ahead of the question. A dry run, and an absent key, ask nothing and delete nothing. A run whose input is not a terminal and that was not given --yes refuses with invalid-input, Nothing to answer with.
A held lease is refused with reference-held: an unexpired lease is one a running process believes is its own, and deleting it lets a second process take the node's cells while the first still serves them. Stop the process on that node, or wait for the lease to run out, and drop it then. --force deletes it anyway, and the question says so in as many words.
Exactly that key is deleted and never a prefix — the rest of the fleet's leases are other nodes' fences. The key is then read back rather than reported deleted: a store that answered a HEAD for an object it would not GET is not one to take a 204 from on trust. The closing line says either that the key is gone and the node's process writes its lease again the next time it starts, or that the store still answers for it, in which case take it up with the store before restarting the node.
| JSON keys | On |
|---|---|
application, node (when given), leases[] — each with node, key, listed, head, get, verdict, why, and expiresAt / recordNode when the body said so | show |
application, node, key, dryRun, force, deleted, before, and after a real deletion after and stillThere | drop |
#Only on verified Celld releases
Like scafo deployment prune, it reads inside Celld's own part of the bucket, so it runs only while a node is answering on a Celld release this build of scafo was verified against, and refuses with capability-missing otherwise — a --dry-run and a show included, with no flag that lifts it. The check is the one prune's release check describes.
#scafo deploy
scafo deploy [dir] [flags]Deploy a built project directory to a node. Talks to the node API: POST /v1/applications/:name/deploys with the packed directory as the raw body, then polls the returned Location every second until the deploy settles in deployed or failed.
| Argument | Type | Required | Default | Description |
|---|---|---|---|---|
dir | positional | no | . | The built project directory to pack |
--name | string | no | the config's own name | Application to deploy into |
--env | string | no | — | Deploy into the application <name>-<env> instead |
--var | string | no | — | Set a variable in the artifact's runtime config, overriding the config's own vars; repeat the flag for several. Written KEY:VALUE and split at the first colon, so a value may contain colons |
--bundle / --no-bundle | boolean | no | true | Build the Worker entry on the node; --no-bundle sets no_bundle: true in the artifact's runtime config, for an entry that is already pre-bundled and self-contained |
--replicas | string | no | — | How many nodes serve this application: a whole number, or all |
--node-selector | string | no | — | Label a node must carry to serve this application; one pair per flag (--node-selector region=eu-west-1 --node-selector tier=fast) |
--wait / --no-wait | boolean | no | true | Wait for a place in the node's deploy queue when it is full, honouring what the node says about when to come back; --no-wait fails at once |
--queue-timeout | string, seconds | no | 600 | How long to keep waiting for a place in the node's queue before giving up, measured from the first try. The default is the node's own queue deadline |
--node | string | no | — | Base URL of the node daemon; without it the chosen remote says where |
--remote | string | no | — | Remote to deploy to, out of this operator's home |
--key | string | no | — | Operator key to sign with |
--config | string | no | — | Path to scafo.config.jsonc, read for its remote pin |
--token | string | no | — | Bearer token to present instead of signing one; a single-use node token cannot watch |
Without --name, the CLI reads name out of the project directory's wrangler.jsonc, then wrangler.json. It is read only and never written back, and the name the config carries reaches Celld exactly as written — --name and --env choose the Scafo application and edit nothing. A directory with no config, or one whose name is missing, blank or no application name at all, is refused with invalid-input, No application to deploy to, telling the caller to pass --name.
--replicas and --node-selector are sent only when given: a deploy that names neither leaves whatever the application already carries exactly as it is. Neither flag can lift a constraint — that is scafo app selector and scafo app replicas.
A node publishes one deploy at a time and queues the rest; a node with no room answers deploy-overloaded (429) with a retry-after. The command honours that number — backing off with jitter when the node named none, and never coming back sooner than it asked — until --queue-timeout has passed, printing one line when the waiting starts and one when it is admitted. The staged artifact is re-sent each time, never re-packed. A --queue-timeout that is not a non-negative number of seconds is invalid-input, Invalid queue timeout.
--var and --no-bundle edit the project's wrangler.jsonc or wrangler.json — the CLI reads it, applies the edit in memory, and packs the result into the archive; the file in the project directory is never written. A --var entry replaces the config's own vars entry of the same name and leaves every other entry as written; an empty value sets the variable to nothing; a name given twice is refused with invalid-input, as is a pair with no colon in it. Names are letters, digits and underscores, never beginning on a digit, and never __proto__, constructor or prototype. A config that already declares no_bundle: true needs no flag. With neither flag the archive carries the config byte for byte.
Warning
A value passed with --var is visible in the process list and the CI log of the machine running the CLI, and Celld stores variables as plain text in the bucket, so anyone with bucket read access can read them. Celld has no secret store. Keep defaults in the config's vars, pass the values that differ between applications from the CI's secret store as --var, mask them in CI logs, and treat bucket read access as access to every variable.
Warning
Wrangler's env.<name> configuration sections are not supported: Celld refuses a config that carries an env key. Keep per-target differences out of the config and pass them with --var.
With no --node and no remote, the command refuses with invalid-input, No node to deploy to.
Warning
A node token is spent by the first request that presents it, so --token with one deploys but cannot poll. The refusal during polling is a clarified auth-invalid: Deploy accepted, but it cannot be watched. Sign with an operator key, or present a workload token the application's trust policy admits, to watch a deploy through to its outcome.
#scafo deployment
Manage what an application has published. One subcommand.
#scafo deployment prune
scafo deployment prune [bucket flags] --name <name> [--env <name>] [--keep] [--older-than] [--yes] [--dry-run]Delete the old Celld deployments of one application. Writes the bucket directly — the deployments live in the application's Celld fleet, and no node holds this state.
Warning
Unstable. Celld keeps every deployment it has ever published and deletes none of them; until it grows a deployment lifecycle of its own, this command is an operator's hands on that space. It warns on every run — scafo: warning: "scafo deployment prune" is subject to change: Celld may grow a native deployment lifecycle (denoland/celld#181), and this command follows upstream when it does — before anything is read or deleted. SCAFO_SILENCE_WARNINGS=1 silences that and every other CLI warning; warnings go to stderr, so piped output stays clean.
| Flag | Type | Required | Default | Description |
|---|---|---|---|---|
| bucket flags | string | no | — | As above |
--name | string | yes | — | Application to prune deployments of |
--env | string | no | — | Address the application <name>-<env> instead |
--keep | string | no | 10 | How many of the newest deployments to keep. A whole number, zero or more; anything else is invalid-input, Invalid --keep |
--older-than | string | no | 24h | Only delete deployments older than this: 30d, 12h, 45m, a whole number of seconds (3600), or 0. Anything else is invalid-input, Invalid --older-than |
--yes | boolean | no | off | Delete without asking |
--dry-run | boolean | no | off | Print what would go, and delete nothing |
#The two gates
Both are always weighed. A deployment goes only when it is past the count and older than the window; 0 lifts whichever gate it is given to, and neither can be lifted by leaving the flag out. A deployment scafo has no record of is weighed as the oldest thing in the fleet.
| Reason a deployment stays | Printed as |
|---|---|
| A Celld pointer names it — the fleet-wide selector, or a script's own | current, the fleet's selector points at it |
Among the newest --keep | within the newest 10 deployments |
Younger than --older-than | younger than 24h |
A pointer protects absolutely: what a pointer names is never a candidate, whatever the two gates say. A fleet whose fleet-wide pointer is missing or unreadable is refused outright with store-nonconforming rather than pruned against no protection at all.
#Asset blobs
Deployments share content-addressed asset blobs under the application's deploy-blobs/ prefix. After the deployments are weighed — and measured against the set that stays, not the one that goes — a blob no kept deployment's assets.json references is collected, once it is older than the same --older-than window. A blob whose age the store did not report is treated as too young to touch.
An assets.json that cannot be read, is not an object, has no entries, or names an unusable digest refuses the whole plan with store-nonconforming: scafo will not delete a blob beside a deployment whose references it cannot read.
Warning
--older-than 0 lifts the age gate on the blobs and the images too, and prune says so before it prints the plan: a deploy in flight uploads its files before it is visible, and pruning now can delete one that is happening; do not prune while anyone deploys. The window is the only thing that tells an unreferenced blob or image from one that is about to be referenced.
#Container images
The container images Celld saved for the application's container classes are shared the same way, one <content key>.tar per image under deploy/images/. They are collected by the same rule, after the blobs: an image that neither containers[].image nor fence_image of any kept deployment's manifest.json names is deleted once it is older than the --older-than window. A deployment with no container names none, and neither does a version directory with no manifest yet.
A manifest whose containers is not a list, or whose image reference is not one Celld writes — celld-image: followed by a lowercase hexadecimal content key — refuses the whole plan with store-nonconforming: scafo will not delete an image beside a deployment whose references it cannot read. Anything under deploy/images/ that is not a <content key>.tar is left where it is.
#What it never touches
Prune weighs only what Celld wrote as a deployment: deploy/<script>/<version>/, with a script name Celld would have accepted and a hexadecimal version id. Anything else beneath deploy/ is left where it is, and two keyspaces are skipped by prefix rather than by the look of their names:
| Prefix | Why |
|---|---|
deploy/images/ | Container images Celld saved, shared by every deployment that runs one. Never weighed as a deployment; collected only by reference, as above |
deploy/queues/ | A queue's consumer attachment, which outlives the deploy that wrote it |
#Asking, and the answers
A real run prints the plan — one line per deployment, then the blobs, then the images — and then asks:
Delete 3 deployments (41 objects), 12 asset blobs and 2 container images of "shop"? [y/N]Only y or yes, in any casing, is consent. --yes answers ahead of the question. A dry run and an empty plan ask nothing. A run whose input is not a terminal and that was not given --yes refuses with invalid-input, Nothing to answer with, rather than taking silence for consent.
Deletion order is the record before its objects, then the blobs, and the images last. The pointers are re-read before every deployment's deletion: a pointer that has moved onto a selected deployment refuses the run with cas-conflict, Deployment is in use again, and nothing of that deployment is deleted — plan the prune again.
#Verified Celld releases only
Prune reads and deletes inside Celld's own part of the bucket, in formats Celld owns and changes between releases. It therefore plans nothing — a --dry-run included — unless two checks hold, and there is no flag that lifts either:
| Check | Refused when |
|---|---|
| Release | Any node that is still answering reports a Celld release this build of scafo was not verified against (0.5.0, 0.5.1, 0.6.0, 0.6.1 and 0.6.2), or reports none; or no node is answering at all, because then nothing says which release wrote the bucket. Records of nodes that stopped answering are ignored. |
| Format | Any deployment's manifest.json carries a schema_version or a required feature this build does not know, any assets.json it reads carries an unknown schema_version, or a manifest is there but cannot be read. Every deployment the plan lists is checked, kept and deleted alike. |
Both are capability-missing: the detail names what was found, what this build is verified against, and that nothing was planned or deleted. A newer scafo, verified against that release, is what lifts it. A version directory with no manifest.json is a deploy in flight — Celld writes the manifest after the files — and is weighed by the age window like anything else.
#scafo trust
Manage which CI issuers may deploy an application. Writes the bucket directly, because a trust policy is usually set up before there is a node to ask — a policy may exist before its application does, and the first deploy creates the application under the name it names.
All subcommands take the bucket flags and name their target with --name and --env; --name is required.
| Command | Extra flags | Description |
|---|---|---|
scafo trust set | --issuer (required), --subject (required), --audience | State every subject an issuer is trusted for. Replaces the subjects that issuer and audience had; other issuers are untouched |
scafo trust add | --issuer (required), --subject (required), --audience | Trust more subjects of an issuer, keeping the ones it has. Writes the issuer, or the policy, when it is not there yet; a subject already trusted changes nothing. The command a trust-mismatch refusal ends with |
scafo trust show | — | Print the issuers an application trusts |
scafo trust remove | --issuer (required) | Stop trusting an issuer |
| Flag | Description |
|---|---|
--issuer | Issuer URL, as its tokens carry it in iss |
--audience | Audience the workload requests its token for; defaults to scafo |
--subject | Subject pattern to trust, * matching any run of characters. Repeat the flag for several; each occurrence is one subject and nothing is split on commas |
#scafo metrics
scafo metrics [bucket flags] [--name] [--env] [--since] [--json]What the fleet served, per application. Read straight out of the bucket: no node is asked and none has to be up.
| Flag | Type | Default | Description |
|---|---|---|---|
--name | string | — | Only this application |
--env | string | — | Narrow to the application <name>-<env> instead |
--since | string | 1h | How far back to read: a duration (90s, 15m, 1h, 7d) or an ISO-8601 instant |
--json | boolean | off | Print the summary as JSON instead of a table |
The snapshots are monotonic totals per node, so a window is a subtraction. Three distinct silences are reported distinctly: nothing was ever written, something was written outside the window, or the nodes observe themselves and count no requests.
#scafo traces
scafo traces [bucket flags] [--name] [--env] [--since] [--limit] [--all] [--json]What the nodes did, in order. Read straight out of the bucket.
| Flag | Type | Default | Description |
|---|---|---|---|
--name | string | — | Only what belongs to this application |
--env | string | — | Narrow to the application <name>-<env> instead |
--since | string | 1h | How far back to read: a duration or an ISO-8601 instant |
--limit | string | 200 | Events to show at most, newest kept; must be a whole number of at least 1 |
--all | boolean | off | Include the reconcile loop's own passes, which every node emits every 5s whether or not anything happened |
--json | boolean | off | Print the events as JSON instead of lines |
Reconcile-pass events are held back by default so they cannot fill the window; how many were held back is always said.
#scafo wg
Set up and refresh this node's WireGuard tunnel. Reads and writes the bucket (publishes this node's public half, reads every peer's) and writes local files. It never brings the interface up: run wg-quick yourself.
Both subcommands take the same arguments.
| Flag | Type | Description |
|---|---|---|
| bucket flags | string | As above |
--node-id | string | Id this node registers under |
--state-dir | string | Directory this node keeps its identity in |
--transport | string | What carries peer traffic (wireguard, none) |
--listen | string | host:port the node API binds to |
--advertise | string | host:port other nodes reach this one at |
--wg-address | string | This node's tunnel address with its prefix length (10.83.0.1/24) |
--wg-endpoint | string | host:port peers send this node's tunnel traffic to |
--wg-port | string | UDP port the interface listens on (51820) |
--stdout | boolean | Print the configuration instead of writing it |
| Command | Description |
|---|---|
scafo wg init | Set this node up on the tunnel and render its interface configuration. Sources or generates a keypair, publishes the public half, then renders |
scafo wg peers | Re-render the interface configuration from the nodes now registered. Reads the keypair, never makes one; a state directory holding none is invalid-input |
With --stdout the configuration is the only thing on stdout; everything else goes to stderr, so --stdout > wg0.conf captures a file wg-quick can parse. Without it, the file is written owner-readable inside the state directory and the command prints the wg-quick up line to run.
A node whose transport is not wireguard is refused with invalid-input, This node runs no WireGuard tunnel.
#Environment variables read by the CLI
| Variable | Read by | Meaning |
|---|---|---|
SCAFO_REMOTE | every remote-facing command | Remote to act on, when no --remote is given |
SCAFO_OPERATOR_KEY | every remote-facing command | An operator key outright: a path to a key file, a path to a portable file, or an inline base64: value. The CI shape — one secret string, no files to place |
Configuration variables (SCAFO_S3_*, SCAFO_LISTEN, …) are documented at /config.