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.

OutcomestderrExit code
Successcommand's own output on stdout0
A scafo failurescafo: <title>[: <detail>] [urn:scafo:<code>]1
Anything elsescafo: 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.

FlagDescription
--configPath to scafo.config.jsonc
--endpointS3-compatible endpoint URL
--regionSigning region of the bucket
--bucketBucket holding the registry
--prefixKey prefix within the bucket
--access-keyBucket access key id
--secret-keyBucket 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.

FlagDescription
--configPath to scafo.config.jsonc, read for its remote pin
--remoteRemote to act on, out of this operator's home
--nodeNode to reach, instead of the remote's first endpoint
--keyOperator 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:

FlagTypeDescription
--namestringThe application to act on
--envstringAddress 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.

FlagTypeDescription
--configstringPath to scafo.config.jsonc
--endpointstringS3-compatible endpoint URL
--regionstringSigning region of the bucket
--bucketstringBucket holding the registry
--prefixstringKey prefix within the bucket
--access-keystringBucket access key id
--secret-keystringBucket secret access key
--node-idstringId this node registers under
--state-dirstringDirectory this node keeps its identity in
--node-labelsstringWhat this node is, for applications to select on; one pair per flag (--node-labels region=eu-west-1 --node-labels tier=fast)
--listenstringhost:port the node API binds to
--advertisestringhost:port other nodes reach this one at
--transportstringWhat carries peer traffic (wireguard, none)
--wg-addressstringThis node's tunnel address with its prefix length (10.83.0.1/24)
--wg-endpointstringhost:port peers send this node's tunnel traffic to
--wg-portstringUDP port the tunnel interface listens on (51820)
--platform-domainsstringApex domain to serve; repeat the flag for several
--ingress-tokenstringToken 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-binstringExecutable that runs one application
--celld-envstringUpstream Celld variable for every runtime process; one NAME=value pair per flag, and a value written here may hold anything, commas included
--port-range-startstringFirst port runtime processes use (24000)
--port-range-endstringLast port runtime processes use; with --network host these are the host's ports, so fit the range to the machine (24998)
--port-quarantine-msstringHow 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-hoststringAddress runtime processes bind their listeners to
--registry-resync-msstringHow 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-msstringHow 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-msstringLifetime written into the coordinator lease, in milliseconds; it is renewed at a third of it (45000)
--coordinator-reap-interval-msstringTime between two reap passes while this node coordinates (600000)
--artifact-bytesstringMaximum compressed and expanded bytes per deploy artifact
--archive-entriesstringMaximum entries per deploy archive
--active-tasksstringMaximum active deploy tasks on this node
--application-active-tasksstringMaximum active deploy tasks for one application
--queued-tasksstringMaximum receiving and queued deploys
--application-queued-tasksstringMaximum receiving and queued deploys for one application
--staged-bytesstringMaximum staged deploy bytes on this node
--application-staged-bytesstringMaximum staged deploy bytes for one application
--receive-timeout-msstringArtifact receive deadline in milliseconds
--queue-timeout-msstringQueued deploy deadline in milliseconds
--task-timeout-msstringActive deploy deadline in milliseconds
--output-bytesstringSubprocess output tail retained in memory
--observability / --no-observabilitybooleanReport what this node does: emit its own events, and give every runtime process CELLD_OTEL=1 unless runtime.celldEnv already states it
--ingress-access-logstringFile 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-publicbooleanServe 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-bytesstringMaximum bytes accepted by a JSON API control request
--traefik-entrypointsstringTraefik entryPoint the published routers attach to; repeat the flag for several (--traefik-entrypoints web --traefik-entrypoints websecure)
Read more in Config.

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

FlagTypeDescription
bucket flagsstringAs above; the loader asks for them
--listenstringhost:port the node API binds to
--transportstringWhat carries peer traffic (wireguard, none)
--wg-addressstringThis 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.

ArgumentTypeRequiredDescription
namepositionalnoName to reach this remote by
--endpointstring—Base URL of one of its nodes; repeat the flag for several
--keystringnoReuse a key already in this home, instead of minting a fresh one
--fromstringnoImport a portable file: a path, or an inline base64: value
--registerbooleannoRecord the key's public half in the registry, bucket-side (admins only)
--rolestringnoTier 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)
--configstringnoPath 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 list

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

ArgumentTypeRequiredDescription
namepositionalyesRemote to write out
--outstringnoFile 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.

FlagTypeDescription
bucket flagsstringAs above, for --register
--outstringFile to write the key pair to (scafo-operator.json)
--idstringId to register the key under (op-<hex>)
--registerbooleanRecord the public half in the registry
--rolestringTier 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.

FlagTypeDescription
--configstringPath to scafo.config.jsonc, read for its remote pin
--remotestringRemote whose key to show, out of this operator's home
--keystringKey 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).

ArgumentTypeRequiredDescription
pubkeypositionalyesPublic document from scafo operator show: a path, an inline base64: value, or the document itself
--remotestringnoRemote whose node admits the key, admin-authed
--rolestringnoTier to admit it at: admin or operator
--appsstringnoApplication 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)
--nodestringnoNode to reach, instead of the remote's first endpoint
bucket flagsstringnoSelects 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.

FlagTypeDescription
--configstringPath to scafo.config.jsonc, read for its remote pin
--remotestringRemote to act on, out of this operator's home
--nodestringNode to reach, instead of the remote's first endpoint
--keep-oldbooleanStop after the flip, leaving the old key trusted as well
--purge-oldbooleanDelete 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.

FlagTypeRequiredDescription
--idstringyesKey to stop trusting; not your own
--config, --remote, --nodestringnoAs 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.

FlagTypeDescription
remote flagsstringAs above
--reasonstringWhy, 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.

CommandExtra argumentsReaches the installationDescription
scafo app list--jsonnode onlyList the applications this key reaches, and which of them are blocked
scafo app show--jsonnode onlyShow one application: its state, placement and labels
scafo app block—node onlyStop serving and deploying an application, reversibly (admin only)
scafo app unblock—node onlyPut a blocked application back into service (admin only)
scafo app labelpairs trailing positionals, optionalnode onlyReplace an application's labels, which scafo stores and never reads (admin only)
scafo app retire—node, else bucket flagsStop serving an application, reversibly
scafo app unretire—node, else bucket flagsPut a retired application back into service
scafo app selector--set <key=value> (repeat the flag for several), --clearnode, else bucket flagsChoose which nodes may serve an application
scafo app replicas--count (required)node, else bucket flagsChoose how many nodes serve an application: all, or a whole number of at least 1
scafo app domain addhost positional (required), --wildcard, --jsonnode, else bucket flagsClaim a host for an application; the first claim of the installation wins
scafo app domain removehost positional (required), --jsonnode, else bucket flagsDrop a host this application holds
scafo app domain list--jsonnode, else bucket flagsThe hosts claimed for an application
scafo app delete--forcenode, else bucket flagsDelete a retired application; --force skips the retirement and grace requirements
scafo app lease show--node, --jsonbucket onlyCelld'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, --jsonbucket onlyDelete 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.

ArgumentTypeRequiredDescription
hostpositionalyes, on add and removeThe host itself, fully qualified: shop.example
--wildcardbooleannoadd only. Claim every name one label under the host too: <env>.<host> answers from <name>-<env>, and any other subdomain from the application itself
--jsonbooleannoPrint 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 keysOn
application, host, platformHostsadd and remove
dns: ok | pending | unknownadd
wildcardDns: same valuesadd, when the claim covers its subdomains
application, domains, wildcardDomainslist

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.

Read more in Docs > Custom Domains.

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.

FlagTypeRequiredDefaultDescription
bucket flagsstringno—As above
--namestringyes—Application whose Celld fleet holds the leases
--envstringno—Address the application <name>-<env> instead
--nodestringdrop onlyevery leaseThe 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
--forcebooleannooffdrop only. Delete a lease that is still held, which a running process may own
--yesbooleannooffdrop only. Delete without asking; required when the input is not a terminal
--dry-runbooleannooffdrop only. Print the reading and what would go, and delete nothing
--jsonbooleannooffPrint 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.

VerdictThe three reads say
heldThe 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
expiredA node lease whose expiry has passed
unreadableThe 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
absentNothing 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 keysOn
application, node (when given), leases[] — each with node, key, listed, head, get, verdict, why, and expiresAt / recordNode when the body said soshow
application, node, key, dryRun, force, deleted, before, and after a real deletion after and stillTheredrop

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

Read more in Docs > Operations#a Lease The Store Will Not Read.

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

ArgumentTypeRequiredDefaultDescription
dirpositionalno.The built project directory to pack
--namestringnothe config's own nameApplication to deploy into
--envstringno—Deploy into the application <name>-<env> instead
--varstringno—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-bundlebooleannotrueBuild 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
--replicasstringno—How many nodes serve this application: a whole number, or all
--node-selectorstringno—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-waitbooleannotrueWait 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-timeoutstring, secondsno600How 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
--nodestringno—Base URL of the node daemon; without it the chosen remote says where
--remotestringno—Remote to deploy to, out of this operator's home
--keystringno—Operator key to sign with
--configstringno—Path to scafo.config.jsonc, read for its remote pin
--tokenstringno—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.

FlagTypeRequiredDefaultDescription
bucket flagsstringno—As above
--namestringyes—Application to prune deployments of
--envstringno—Address the application <name>-<env> instead
--keepstringno10How many of the newest deployments to keep. A whole number, zero or more; anything else is invalid-input, Invalid --keep
--older-thanstringno24hOnly delete deployments older than this: 30d, 12h, 45m, a whole number of seconds (3600), or 0. Anything else is invalid-input, Invalid --older-than
--yesbooleannooffDelete without asking
--dry-runbooleannooffPrint 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 staysPrinted as
A Celld pointer names it — the fleet-wide selector, or a script's owncurrent, the fleet's selector points at it
Among the newest --keepwithin the newest 10 deployments
Younger than --older-thanyounger 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:

PrefixWhy
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:

CheckRefused when
ReleaseAny 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.
FormatAny 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.

CommandExtra flagsDescription
scafo trust set--issuer (required), --subject (required), --audienceState 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), --audienceTrust 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
FlagDescription
--issuerIssuer URL, as its tokens carry it in iss
--audienceAudience the workload requests its token for; defaults to scafo
--subjectSubject 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.

FlagTypeDefaultDescription
--namestring—Only this application
--envstring—Narrow to the application <name>-<env> instead
--sincestring1hHow far back to read: a duration (90s, 15m, 1h, 7d) or an ISO-8601 instant
--jsonbooleanoffPrint 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.

FlagTypeDefaultDescription
--namestring—Only what belongs to this application
--envstring—Narrow to the application <name>-<env> instead
--sincestring1hHow far back to read: a duration or an ISO-8601 instant
--limitstring200Events to show at most, newest kept; must be a whole number of at least 1
--allbooleanoffInclude the reconcile loop's own passes, which every node emits every 5s whether or not anything happened
--jsonbooleanoffPrint 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.

FlagTypeDescription
bucket flagsstringAs above
--node-idstringId this node registers under
--state-dirstringDirectory this node keeps its identity in
--transportstringWhat carries peer traffic (wireguard, none)
--listenstringhost:port the node API binds to
--advertisestringhost:port other nodes reach this one at
--wg-addressstringThis node's tunnel address with its prefix length (10.83.0.1/24)
--wg-endpointstringhost:port peers send this node's tunnel traffic to
--wg-portstringUDP port the interface listens on (51820)
--stdoutbooleanPrint the configuration instead of writing it
CommandDescription
scafo wg initSet this node up on the tunnel and render its interface configuration. Sources or generates a keypair, publishes the public half, then renders
scafo wg peersRe-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.

Read more in Docs > Wireguard.

#Environment variables read by the CLI

VariableRead byMeaning
SCAFO_REMOTEevery remote-facing commandRemote to act on, when no --remote is given
SCAFO_OPERATOR_KEYevery remote-facing commandAn 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.