Node configuration
Every setting scafo sail and scafo health read, where it can be written, and what it defaults to.
#How a setting is resolved
A node is configured from three sources: the command line, the environment, and a config file. Each setting is resolved on its own, in this order:
scafo sail accepts a flag for every setting on this page. scafo health accepts only --config, the six bucket flags, --listen, --transport and --wg-address — it resolves the listener from the environment and the config file exactly as the daemon does, so a listener chosen with sail --listen alone is invisible to it.
#The config file
Without --config, the daemon looks for these files in order and uses the first one it can read:
./scafo.config.jsonc./scafo.config.jsonBoth are parsed as JSONC: comments and trailing commas are allowed. The top level must be a JSON object; an array or a scalar is refused with invalid-input. A syntax error is refused naming the file and the character offset.
With --config <path>, only that path is read, and it must exist — a missing file is invalid-input ("Config file not found"), never a silent fall-back to the defaults.
Keys are addressed by the dotted path in the tables below (bucket.endpoint, runtime.celldEnv), which is how a refusal names them too.
#Blank values
A blank or whitespace-only value counts as unset in every source: an empty SCAFO_S3_PREFIX is the same as no SCAFO_S3_PREFIX at all. The one exception is the ingress token, where an explicitly blank value is refused rather than ignored, because an empty token would admit a caller presenting nothing.
#Booleans
observability.enabled and api.public accept a JSON boolean in the config file, or the text 1, true, 0, false (case-insensitive, surrounding spaces trimmed) from a flag, an environment variable or a JSON string. Anything else is refused with invalid-input. On the command line the switches are --observability / --no-observability and --api-public / --no-api-public; a switch nobody wrote leaves the environment and the config file to answer.
#Lists and maps
Five settings hold more than one value, and each source spells that out its own way:
- On the command line, repeat the flag. Each occurrence is one whole value and nothing is split, so a value may itself contain a comma.
- In the environment, separate the values with commas. A variable cannot repeat, which is why a value written there cannot contain a comma.
- In the config file, write the JSON shape: an array for a list, an object for a map.
| Setting | Repeated flag | Environment variable | Config file |
|---|---|---|---|
node.labels | --node-labels region=eu-west-1 --node-labels tier=fast | region=eu-west-1,tier=fast | object of label keys to values |
runtime.celldEnv | --celld-env NAME=value --celld-env NAME2=value2 | NAME=value,NAME2=value2 | object of variable names to string values |
platformDomains | --platform-domains apps.example.com --platform-domains edge.example.com | apps.example.com,edge.example.com | array of strings |
ingress.token | --ingress-token new --ingress-token old | new,old | one token as a string, or an array of them |
ingress.entryPoints | --traefik-entrypoints web --traefik-entrypoints websecure | web,websecure | array of strings |
For node.labels and runtime.celldEnv the flag and the environment variable replace the file's set wholesale rather than merging into it. Blank entries are skipped, so a trailing comma costs nothing; a duplicate key is refused rather than resolved; text that names nothing at all is refused.
Warning
A Celld variable whose value contains a comma is written by repeating --celld-env, or as runtime.celldEnv in the config file. SCAFO_CELLD_ENV is the one form that cannot carry one, because its entries are separated by commas.
Label keys and values are 1–63 characters, lowercase alphanumerics, dots, dashes and underscores, beginning and ending on an alphanumeric. Environment variable names are letters, digits and underscores, never beginning on a digit, and their values must be JSON strings — a number or a boolean in the config file is refused rather than stringified.
#Addresses
node.listen, node.advertise and wireguard.endpoint are host:port:
- the port is 1–65535;
- an unbracketed host is a lowercase DNS name (labels of 1–63 characters, no trailing dot, at most 253 characters) or an IPv4 address;
- an IPv6 literal must be bracketed the way a URL authority writes one:
[::1]:7400.
runtime.bindHost is a bare host rather than a pair, and additionally accepts the wildcards 0.0.0.0 and ::. wireguard.address is an interface address with its prefix length, such as 10.83.0.1/24. node.id matches ^[a-z\d_-]{1,128}$. Every platformDomains entry is validated as a host — no scheme, no wildcard, no port.
Malformed addresses, node ids and domains are rejected before the daemon writes or binds anything.
#A worked example
The same node, written both ways: a two-node fleet on a WireGuard tunnel, serving *.apps.example.com, with observability on and a colocated Traefik.
{
// The bucket IS the registry: every node pointed at the same bucket and
// prefix is one fleet.
"bucket": {
"endpoint": "https://store.internal.example.com:8333",
"region": "us-east-1",
"bucket": "scafo",
"prefix": "prod",
"accessKey": "AKIAEXAMPLE",
"secretKey": "s3cr3t-example-key",
},
"node": {
// Optional: without it the id in the state directory is used.
"id": "node-a",
// On the tunnel, because that is what this node's peers can reach.
"listen": "10.83.0.1:7400",
"advertise": "10.83.0.1:7400",
"stateDir": "/var/lib/scafo",
// What this machine IS, for applications that select on a class of node.
"labels": { "region": "eu-west-1", "tier": "fast" },
},
// Apex domains applications are served wildcard subdomains under.
"platformDomains": ["apps.example.com"],
"runtime": {
"binPath": "/usr/local/bin/celld",
// Each application holds one pair out of this range, for as long as this
// node knows it. On --network host these are the machine's own ports.
"portRangeStart": 24000,
"portRangeEnd": 24998,
"portQuarantineMs": 600000,
// Runtime listeners follow the tunnel, so peers can reach them.
"bindHost": "10.83.0.1",
// Upstream Celld policy. Scafo sets no policy defaults of its own.
"celldEnv": {
"CELLD_MAX_RSS_MB": "512",
"CELLD_TTL_MS": "30000",
},
},
// How often this node looks past the change hint it reads once a pass.
"registry": {
"resyncIntervalMs": 60000,
"nodeRefreshIntervalMs": 30000,
},
// Only the node currently holding the lease acts on these.
"coordinator": {
"leaseTtlMs": 45000,
"reapIntervalMs": 600000,
},
"deploy": {
"artifactBytes": 1073741824,
"archiveEntries": 20000,
"activeTasks": 2,
"applicationActiveTasks": 1,
"queuedTasks": 64,
"applicationQueuedTasks": 4,
"stagedBytes": 5368709120,
"applicationStagedBytes": 3221225472,
"receiveTimeoutMs": 600000,
"queueTimeoutMs": 600000,
"taskTimeoutMs": 300000,
"outputBytes": 65536,
},
"observability": {
"enabled": true,
// Refused unless "enabled" is true.
"ingressAccessLog": "/var/log/scafo-ingress/access.log",
},
"api": {
"public": true,
"jsonBytes": 65536,
},
"ingress": {
// Primary first. Leave the setting out to have the node mint its own.
"token": ["kZ9x-new-token", "Qp1v-old-token"],
"entryPoints": ["web", "websecure"],
},
"transport": { "driver": "wireguard" },
"wireguard": {
"address": "10.83.0.1/24",
"endpoint": "203.0.113.7:51820",
"port": 51820,
},
}#bucket
The registry store. Every node pointed at the same bucket and prefix belongs to one fleet.
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
bucket.endpoint | SCAFO_S3_ENDPOINT, S3_ENDPOINT | --endpoint | URL | — (required) | S3-compatible endpoint the registry lives behind. Addressed path-style. |
bucket.region | SCAFO_S3_REGION, AWS_REGION | --region | string | us-east-1 | Signing region; harmless for stores that ignore it. |
bucket.bucket | SCAFO_S3_BUCKET | --bucket | string | — (required) | Bucket holding the registry. |
bucket.prefix | SCAFO_S3_PREFIX | --prefix | string | unset | Key prefix every registry object and every runtime fleet prefix lives under, for sharing one bucket between installations. |
bucket.accessKey | SCAFO_S3_ACCESS_KEY, AWS_ACCESS_KEY_ID | --access-key | string | — (required) | Access key id. |
bucket.secretKey | SCAFO_S3_SECRET_KEY, AWS_SECRET_ACCESS_KEY | --secret-key | string | — (required) | Secret access key. |
The prefix names the installation. Changing or removing it points the node at a different, empty installation: nothing moves, and the node records, operator keys, trust policies and fleets under the old prefix stay where they were. Every bucket-reading command run from another machine, such as scafo fleet nodes, scafo trust set or scafo remote add --register, must be given the same prefix as the node, or none if the node has none.
The Scafo-specific spellings outrank the AWS/S3 ones: SCAFO_S3_REGION wins over AWS_REGION, SCAFO_S3_ACCESS_KEY over AWS_ACCESS_KEY_ID, SCAFO_S3_ENDPOINT over S3_ENDPOINT.
#node
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
node.id | SCAFO_NODE_ID | --node-id | ^[a-z\d_-]{1,128}$ | the id in the state directory | Id this node registers under. Without one, the persistent node state supplies it, generating one on first boot. It must be the same on every boot of the machine: each runtime process is started under it, and that is how the process finds the writes its predecessor left on disk. |
node.listen | SCAFO_LISTEN | --listen | host:port | 127.0.0.1:7400; with a WireGuard tunnel address, <tunnel host>:7400 | Address the node API binds to. |
node.advertise | SCAFO_ADVERTISE | --advertise | host:port | unset, and the listener stands in; with a WireGuard tunnel address, <tunnel host>:<listen port> | Address other nodes and operators should reach this one at, when it differs from the listener. |
node.stateDir | SCAFO_STATE_DIR | --state-dir | directory path | /var/lib/scafo when running as uid 0; else $XDG_STATE_HOME/scafo when that is absolute; else ~/.local/state/scafo | Where this node keeps its identity, ingress token and tunnel keys between boots, and the working directory and ports of every runtime process it supervises. It must survive the node being re-created, not only restarted — see durability and the state directory. The official image sets SCAFO_STATE_DIR=/var/lib/scafo. |
node.labels | SCAFO_NODE_LABELS | --node-labels | map | unset | What this machine is, for applications that select on a class of node. Written wholesale to the node record on every boot: a label dropped from the configuration is gone from the record after a restart. |
#platformDomains
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
platformDomains | SCAFO_PLATFORM_DOMAINS | --platform-domains | list of hosts | [] | Apex domains the platform serves wildcard subdomains under. Each entry is validated as a host: no scheme, no wildcard, no port. A fleet with none serves applications no platform host. |
#runtime
How this node runs the Celld processes that serve its applications.
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
runtime.binPath | SCAFO_CELLD_BIN | --celld-bin | executable name or path | celld | Executable that runs one application; resolved against PATH unless it is a path. |
runtime.celldEnv | SCAFO_CELLD_ENV | --celld-env | map | unset | Upstream Celld variables handed to every runtime process on this node. Driver-derived names are refused. |
runtime.portRangeStart | SCAFO_PORT_RANGE_START | --port-range-start | integer 1024–65534 | 24000 | First port of the range this node draws each application's public/internal pair from. Below 1024 is the privileged range an unprivileged runtime process cannot bind anyway. |
runtime.portRangeEnd | SCAFO_PORT_RANGE_END | --port-range-end | integer 1025–65535, above the start | the start plus 998, never past 65535 — so 24998 by default | Last port of that range, included. Worth sizing when the node shares the host's network namespace (--network host): those are the machine's own ports. |
runtime.portQuarantineMs | SCAFO_PORT_QUARANTINE_MS | --port-quarantine-ms | positive safe integer, ms | 600000 (10 min) | How long a port this node released is kept from every other application, so nothing still routing to it can reach the wrong process. |
runtime.bindHost | SCAFO_BIND_HOST | --bind-host | host, 0.0.0.0 or :: | tunnel host with a WireGuard address; 0.0.0.0 when transport.driver is none; otherwise 127.0.0.1 | Address runtime processes bind their two listeners to. |
A range holding fewer than two ports is refused at startup. The pair an application is given is chosen once on this node and then kept — see port pairs.
#registry
How often this node looks at the registry beyond the change hint it reads once a pass. Both are bounds on how stale its view may get, never on correctness.
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
registry.resyncIntervalMs | SCAFO_REGISTRY_RESYNC_MS | --registry-resync-ms | positive safe integer, ms | 60000 (1 min) | How often this node re-reads the whole registry regardless of the change hint. It is the bound on a hint that never arrived: raising it makes a lost hint cost more latency, lowering it makes every node list every kind more often on an installation where nothing happens. |
registry.nodeRefreshIntervalMs | SCAFO_REGISTRY_NODE_REFRESH_MS | --registry-node-refresh-ms | positive safe integer, ms | 30000 (30 s), the heartbeat interval | How often this node re-reads the fleet's node records. Liveness is the age of a heartbeat and no write announces a death, so this is the only thing that notices one; raising it past the 90 s liveness window delays every reassignment, lowering it costs a listing more often. |
#coordinator
What the node does on the ticks it spends holding the coordinator lease. Every node reads these; only the holder acts on them.
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
coordinator.leaseTtlMs | SCAFO_COORDINATOR_LEASE_TTL_MS | --coordinator-lease-ttl-ms | positive safe integer, ms | 45000 (45 s) | Lifetime written into the lease. It is renewed at a third of it, which is also the coordinator loop's tick. |
coordinator.reapIntervalMs | SCAFO_COORDINATOR_REAP_INTERVAL_MS | --coordinator-reap-interval-ms | positive safe integer, ms | 600000 (10 min) | Time between two reap passes while holding. |
A longer lease costs no failover time. What a dead coordinator held is only reassigned once the fleet has decided the node is gone, and that decision is the 90-second liveness window; both clocks start at the same death, so any lifetime up to that window expires while the fleet is still waiting to notice. Shortening it only makes the holder write the lease more often, forever.
Nothing a reap does is urgent — it deletes deploy records a week past their retention and applications a day past their retirement grace, and fails deploys whose node is already gone, which a poll answers at read time anyway. What it costs is one listing of every deploy record in the installation, the largest periodic read there is, so it runs at the cadence of the slowest thing it decides.
#deploy
Bounds applied while receiving, expanding and building a deploy artifact. They are scheduling limits, not an OS admission control.
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
deploy.artifactBytes | SCAFO_ARTIFACT_BYTES | --artifact-bytes | positive safe integer | 1073741824 (1 GiB) | Maximum received compressed bytes and, independently, expanded bytes. |
deploy.archiveEntries | SCAFO_ARCHIVE_ENTRIES | --archive-entries | positive safe integer | 20000 | Maximum entries in one tar archive. |
deploy.activeTasks | SCAFO_ACTIVE_TASKS | --active-tasks | positive safe integer | 1 | Maximum deploy tasks running at once on this node. |
deploy.applicationActiveTasks | SCAFO_APPLICATION_ACTIVE_TASKS | --application-active-tasks | positive safe integer | 1 | Maximum active deploy tasks for one application. |
deploy.queuedTasks | SCAFO_QUEUED_TASKS | --queued-tasks | non-negative safe integer | 64 | Maximum deploys receiving or queued on this node, while one publishes. |
deploy.applicationQueuedTasks | SCAFO_APPLICATION_QUEUED_TASKS | --application-queued-tasks | non-negative safe integer | 4 | Maximum receiving and queued deploys for one application, so one noisy repository cannot take the node's whole queue. |
deploy.stagedBytes | SCAFO_STAGED_BYTES | --staged-bytes | positive safe integer | 5368709120 (5 GiB) | Maximum staged deploy bytes on this node. |
deploy.applicationStagedBytes | SCAFO_APPLICATION_STAGED_BYTES | --application-staged-bytes | positive safe integer | 3221225472 (3 GiB) | Maximum staged deploy bytes for one application. |
deploy.receiveTimeoutMs | SCAFO_RECEIVE_TIMEOUT_MS | --receive-timeout-ms | positive safe integer, ms | 600000 (10 min) | Artifact receive deadline. |
deploy.queueTimeoutMs | SCAFO_QUEUE_TIMEOUT_MS | --queue-timeout-ms | positive safe integer, ms | 600000 (10 min) | Queued deploy deadline. |
deploy.taskTimeoutMs | SCAFO_TASK_TIMEOUT_MS | --task-timeout-ms | positive safe integer, ms | 300000 (5 min) | Active deploy deadline. |
deploy.outputBytes | SCAFO_OUTPUT_BYTES | --output-bytes | positive safe integer | 65536 | Subprocess output tail retained in memory. |
#observability
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
observability.enabled | SCAFO_OBSERVABILITY | --observability / --no-observability | boolean | false | Whether this node reports on itself: its own event ring and the Parquet records flushed to the bucket, plus CELLD_OTEL=1 on every runtime process unless runtime.celldEnv says otherwise. |
observability.ingressAccessLog | SCAFO_INGRESS_ACCESS_LOG | --ingress-access-log | file path | unset | JSON access log the ingress writes; the node follows it from its current end at boot and counts requests, bytes, statuses and latencies per application. Requires observability.enabled. |
#api
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
api.public | SCAFO_API_PUBLIC | --api-public / --no-api-public | boolean | true | Serve the fleet's API at each platform domain's own apex. --no-api-public leaves the apex unrouted and without a certificate. /v1/storage and /v1/ingress/routes are never reachable there. |
api.jsonBytes | SCAFO_JSON_BYTES | --json-bytes | positive safe integer | 65536 | Maximum bytes accepted by any JSON control request. |
#ingress
There is no ingress driver to select. Both supported ingresses pull: Caddy through the scafo dynamic-upstreams module reading /v1/ingress/routes, Traefik through its http provider reading /v1/ingress/routes/traefik. This section is what an ingress needs in order to read this node.
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
ingress.token | SCAFO_INGRESS_TOKEN | --ingress-token | list, primary first; one token in the file is written as a plain string | unset — the node mints one and keeps it in the state directory | Every token admitted on /v1/ingress and /v1/storage. Explicit tokens replace minting; the first is written to the state directory's ingress-token file. |
ingress.entryPoints | SCAFO_TRAEFIK_ENTRYPOINTS | --traefik-entrypoints | list | unset, and the published routers attach to web | Traefik entryPoints the published routers attach to. Never empty: routers attached to none are attached to all of them. |
SCAFO_STORAGE_TOKEN is the name this variable had when the token only guarded storage; it is still accepted, after SCAFO_INGRESS_TOKEN.
Tip
Rotate by expanding the set to [new, old], restarting the node and migrating consumers onto the new token, then contracting to [new] and restarting again. The node admits every token in the set while the rotation is in flight.
#transport
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
transport.driver | SCAFO_TRANSPORT | --transport | wireguard or none | unset, which is only legal for a node that stays on loopback | What carries traffic between this node and its peers. wireguard tunnels it; none states that the network is already private and trusted, such as a cloud vRack. |
#wireguard
Read only when transport.driver is wireguard. Settings written here for a node running another transport are reported as ignored and dropped.
| File key | Env | Flag | Type | Default | Description |
|---|---|---|---|---|---|
wireguard.address | SCAFO_WG_ADDRESS | --wg-address | address/prefixlen | unset | This node's own address on the tunnel with its prefix length, such as 10.83.0.1/24. |
wireguard.endpoint | SCAFO_WG_ENDPOINT | --wg-endpoint | host:port | unset | Where peers send this node's tunnel traffic, such as 203.0.113.7:51820. |
wireguard.port | SCAFO_WG_PORT | --wg-port | integer 1–65535 | 51820 | UDP port the tunnel interface listens on. |
#Required settings
Only the four bucket settings are hard-required: bucket.endpoint, bucket.bucket, bucket.accessKey and bucket.secretKey. A missing one refuses the boot with invalid-input ("Missing required configuration"), naming all three ways to supply it:
scafo: Missing required configuration: "bucket.endpoint" has no value: set it in the
config file, set SCAFO_S3_ENDPOINT or S3_ENDPOINT in the environment, or pass
--endpoint [urn:scafo:invalid-input]Everything else has a default or is legitimately absent.
#Cross-setting rules
These are checked at load or at boot, before anything is written to the bucket or bound to an address.
| Rule | Refusal |
|---|---|
registry.resyncIntervalMs, registry.nodeRefreshIntervalMs, coordinator.leaseTtlMs and coordinator.reapIntervalMs are each a positive safe integer | invalid-input |
runtime.portQuarantineMs is a positive safe integer | invalid-input |
runtime.portRangeStart is a port in 1024–65534, and runtime.portRangeEnd is above it and no higher than 65535, so the range holds at least two ports | invalid-input "Invalid port range" |
deploy.applicationActiveTasks ≤ deploy.activeTasks | invalid-input "Invalid deploy limits" |
deploy.applicationQueuedTasks ≤ deploy.queuedTasks | invalid-input "Invalid deploy limits" |
deploy.applicationStagedBytes ≤ deploy.stagedBytes | invalid-input "Invalid deploy limits" |
deploy.stagedBytes ≥ deploy.artifactBytes | invalid-input "Invalid deploy byte limits" |
deploy.applicationStagedBytes ≥ deploy.artifactBytes | invalid-input "Invalid deploy byte limits" |
observability.ingressAccessLog requires observability.enabled | invalid-input "Access log configured with observability off" |
ingress.entryPoints, when set, names at least one entryPoint | invalid-input "Invalid configuration value" |
ingress.token, when set, is a token or a list of them and holds no empty entry | invalid-input "Invalid configuration value" |
runtime.celldEnv sets no name the runtime driver derives itself | invalid-input "Reserved environment variable" |
transport.driver is wireguard or none | invalid-input "Invalid configuration value" |
node.listen, node.advertise or runtime.bindHost beyond loopback requires transport to be set | invalid-input "No transport configured" |
transport.driver: wireguard requires both wireguard.address and wireguard.endpoint | invalid-input "Incomplete WireGuard configuration" |
transport.driver: wireguard requires a tunnel keypair in the state directory | invalid-input "WireGuard tunnel is not set up" |
Note
The wildcards 0.0.0.0 and :: count as beyond loopback: they bind every interface the machine has, loopback being only one of them. That is why transport must be stated before the node API, the advertised address or the runtime bind host leaves 127.0.0.0/8.
The transport check runs before the node registers itself, and the tunnel-keys check runs immediately after it: a node that should not be on this network must not have registered on it first, and a node whose transport is a tunnel it has never been set up on must not register an address its peers cannot reach.
#Other environment variables
These are not settings of the configuration above; the daemon, the CLI or the image reads them directly.
| Variable | Read by | Effect |
|---|---|---|
XDG_STATE_HOME | the CLI, when node.stateDir is unset and the process is not uid 0 | State directory becomes $XDG_STATE_HOME/scafo when the value is absolute; a relative value is ignored. |
CELLD_ESBUILD | the deploy runner | Passed through to the build subprocess that bundles a deploy. The official image sets it to /usr/local/bin/esbuild; the installer sets it in the systemd unit. |
CELLD_DOCKER, CELLD_CONTAINER_PLATFORM | the deploy runner | Passed through, unread, to the publish that builds or pulls a container class's image: the engine client to run in place of docker, and the platform the image is built for in place of linux/amd64. Set them in the daemon's own environment, not in runtime.celldEnv, which never reaches the publish. |
CELLD_PYTHON_RUNTIME_DIR | the deploy runner | Passed through, unread, to the publish: where a Python Worker's build keeps the Pyodide runtime it downloads, in place of Celld's cache under HOME. |
CELLD_MAX_ASSET_FILE_BYTES | the deploy runner | Passed through, unread, to the publish, which refuses a static asset file larger than it. Every runtime process checks the same limit, so a raised value goes in runtime.celldEnv too — see container engine. |
PATH, HOME, TMPDIR, TZ, RUST_LOG, DOCKER_HOST | the reconciler and the deploy runner | The only variables of the daemon's own environment handed to a runtime or build process, besides CELLD_ESBUILD, CELLD_DOCKER, CELLD_CONTAINER_PLATFORM, CELLD_PYTHON_RUNTIME_DIR and CELLD_MAX_ASSET_FILE_BYTES for the build. Everything else a process gets comes from runtime.celldEnv or the driver. DOCKER_HOST is passed on unread, so Celld finds the container engine socket the daemon was given. |
Celld's own variables — CELLD_SHUTDOWN_TOTAL_MS, CELLD_TTL_MS, CELLD_OTEL and the rest of the upstream policy — are set through runtime.celldEnv rather than in the daemon's own environment.
#Files under the state directory
Everything here is owner-readable only (0600), in a directory Scafo creates 0700.
| File | Written by | Holds |
|---|---|---|
node.json | the daemon, on first boot | This node's persistent id and its Ed25519 signing key. Losing it means the machine comes back as a different node. |
celld/<application>/work/ | each runtime process, while it runs | The application's local runtime state on this node, including the copy of a write that has been acknowledged before the bucket has it. Losing it loses those writes. |
celld/<application>/assets/ | each runtime process, while it runs | The static assets that application has downloaded, cached per application rather than shared across the machine. |
celld/<application>/ports.json | the daemon, when the application first starts here | The two ports this application holds on this node, reused on every later start. |
celld/.released.json | the daemon, when an application's local state goes | Ports released here and when, so a freed port is not handed to another application while anything may still route to it. |
ingress-token | the daemon, every boot | The primary ingress token, minted when ingress.token names none and rewritten from the setting when it does. Mount it read-only into the process that terminates TLS. |
wg-private.key | scafo wg init | The tunnel private key, as wg genkey writes one. Never sent to the registry. |
wg-public.key | scafo wg init | The tunnel public key, as wg pubkey writes one. This is what peers are told, through the node's registry record. |
wg0.conf | scafo wg init and scafo wg peers | The rendered interface configuration, ready for wg-quick. |
The per-application directories are kept through every stop — a retirement, a block, a reassignment, a drain, a daemon restart — because the disk may still hold writes a peer needs and the application may be served here again. One is removed only by the pass that finds the application gone from the registry altogether, and never while a process for it is running here.
Warning
Put the state directory on storage that outlives the node being re-created, not only restarted: a volume, a PVC, a real directory on the host. See durability and the state directory.
Warning
The ingress token guards /v1/ingress and /v1/storage, and the storage surface carries private key material in both directions. Give the token to local or tunnelled callers only.
#What the installer writes
scripts/install.sh, run as root on a systemd host, adds these on top of the state directory:
| Path | Mode | Contents |
|---|---|---|
/etc/scafo/node.env | 0600 | The node's environment file, read by scafo-node.service as EnvironmentFile=. Pre-filled with the bucket variables, SCAFO_PLATFORM_DOMAINS, SCAFO_LISTEN=127.0.0.1:7400, and commented examples for advertise, transport, labels, Celld policy and observability. Kept, never rewritten, on reinstall. |
/etc/scafo/caddy.json | 0644 | The generated ingress configuration. Its scafo app declares one fleet, http://127.0.0.1:7400 with the ingress token as token_file from the unit's credential directory, and the scafo storage, dynamic upstreams and on-demand permission read that fleet. Kept, never rewritten, on reinstall. |
/etc/systemd/system/scafo-node.service | — | The node unit. Sets SCAFO_STATE_DIR=/var/lib/scafo, CELLD_ESBUILD and SCAFO_CELLD_BIN, runs scafo sail as the scafo user, KillSignal=SIGTERM with TimeoutStopSec=50s. Written, not started. |
/etc/systemd/system/caddy.service | — | The ingress unit; receives a private systemd-credential copy of the ingress token. Written, not started. |
/var/lib/scafo/install.manifest | — | What that run installed, for scafo-uninstall. |
/var/lib/caddy | — | The ingress unit's StateDirectory: the ingress's ACME account, never the certificates, which are in the bucket. |
/var/log/scafo-ingress/ | — | Where the ingress writes its access log, when asked to. |
Binaries go to /usr/local/bin as root, or ~/.local/bin otherwise. Uninstalling never touches the bucket, and preserves /var/lib/scafo unless --purge is given.