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:

the command-line flag, if it was passed;
the environment variables listed for it, first listed wins;
the dotted key in the config file;
the built-in default.

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

Both 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.
SettingRepeated flagEnvironment variableConfig file
node.labels--node-labels region=eu-west-1 --node-labels tier=fastregion=eu-west-1,tier=fastobject of label keys to values
runtime.celldEnv--celld-env NAME=value --celld-env NAME2=value2NAME=value,NAME2=value2object of variable names to string values
platformDomains--platform-domains apps.example.com --platform-domains edge.example.comapps.example.com,edge.example.comarray of strings
ingress.token--ingress-token new --ingress-token oldnew,oldone token as a string, or an array of them
ingress.entryPoints--traefik-entrypoints web --traefik-entrypoints websecureweb,websecurearray 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 keyEnvFlagTypeDefaultDescription
bucket.endpointSCAFO_S3_ENDPOINT, S3_ENDPOINT--endpointURL— (required)S3-compatible endpoint the registry lives behind. Addressed path-style.
bucket.regionSCAFO_S3_REGION, AWS_REGION--regionstringus-east-1Signing region; harmless for stores that ignore it.
bucket.bucketSCAFO_S3_BUCKET--bucketstring— (required)Bucket holding the registry.
bucket.prefixSCAFO_S3_PREFIX--prefixstringunsetKey prefix every registry object and every runtime fleet prefix lives under, for sharing one bucket between installations.
bucket.accessKeySCAFO_S3_ACCESS_KEY, AWS_ACCESS_KEY_ID--access-keystring— (required)Access key id.
bucket.secretKeySCAFO_S3_SECRET_KEY, AWS_SECRET_ACCESS_KEY--secret-keystring— (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 keyEnvFlagTypeDefaultDescription
node.idSCAFO_NODE_ID--node-id^[a-z\d_-]{1,128}$the id in the state directoryId 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.listenSCAFO_LISTEN--listenhost:port127.0.0.1:7400; with a WireGuard tunnel address, <tunnel host>:7400Address the node API binds to.
node.advertiseSCAFO_ADVERTISE--advertisehost:portunset, 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.stateDirSCAFO_STATE_DIR--state-dirdirectory path/var/lib/scafo when running as uid 0; else $XDG_STATE_HOME/scafo when that is absolute; else ~/.local/state/scafoWhere 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.labelsSCAFO_NODE_LABELS--node-labelsmapunsetWhat 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 keyEnvFlagTypeDefaultDescription
platformDomainsSCAFO_PLATFORM_DOMAINS--platform-domainslist 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 keyEnvFlagTypeDefaultDescription
runtime.binPathSCAFO_CELLD_BIN--celld-binexecutable name or pathcelldExecutable that runs one application; resolved against PATH unless it is a path.
runtime.celldEnvSCAFO_CELLD_ENV--celld-envmapunsetUpstream Celld variables handed to every runtime process on this node. Driver-derived names are refused.
runtime.portRangeStartSCAFO_PORT_RANGE_START--port-range-startinteger 1024–6553424000First 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.portRangeEndSCAFO_PORT_RANGE_END--port-range-endinteger 1025–65535, above the startthe start plus 998, never past 65535 — so 24998 by defaultLast 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.portQuarantineMsSCAFO_PORT_QUARANTINE_MS--port-quarantine-mspositive safe integer, ms600000 (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.bindHostSCAFO_BIND_HOST--bind-hosthost, 0.0.0.0 or ::tunnel host with a WireGuard address; 0.0.0.0 when transport.driver is none; otherwise 127.0.0.1Address 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.

Read more in Config > Celld.

#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 keyEnvFlagTypeDefaultDescription
registry.resyncIntervalMsSCAFO_REGISTRY_RESYNC_MS--registry-resync-mspositive safe integer, ms60000 (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.nodeRefreshIntervalMsSCAFO_REGISTRY_NODE_REFRESH_MS--registry-node-refresh-mspositive safe integer, ms30000 (30 s), the heartbeat intervalHow 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.
Read more in Docs > Registry#how A Node Follows The Registry.

#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 keyEnvFlagTypeDefaultDescription
coordinator.leaseTtlMsSCAFO_COORDINATOR_LEASE_TTL_MS--coordinator-lease-ttl-mspositive safe integer, ms45000 (45 s)Lifetime written into the lease. It is renewed at a third of it, which is also the coordinator loop's tick.
coordinator.reapIntervalMsSCAFO_COORDINATOR_REAP_INTERVAL_MS--coordinator-reap-interval-mspositive safe integer, ms600000 (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 keyEnvFlagTypeDefaultDescription
deploy.artifactBytesSCAFO_ARTIFACT_BYTES--artifact-bytespositive safe integer1073741824 (1 GiB)Maximum received compressed bytes and, independently, expanded bytes.
deploy.archiveEntriesSCAFO_ARCHIVE_ENTRIES--archive-entriespositive safe integer20000Maximum entries in one tar archive.
deploy.activeTasksSCAFO_ACTIVE_TASKS--active-taskspositive safe integer1Maximum deploy tasks running at once on this node.
deploy.applicationActiveTasksSCAFO_APPLICATION_ACTIVE_TASKS--application-active-taskspositive safe integer1Maximum active deploy tasks for one application.
deploy.queuedTasksSCAFO_QUEUED_TASKS--queued-tasksnon-negative safe integer64Maximum deploys receiving or queued on this node, while one publishes.
deploy.applicationQueuedTasksSCAFO_APPLICATION_QUEUED_TASKS--application-queued-tasksnon-negative safe integer4Maximum receiving and queued deploys for one application, so one noisy repository cannot take the node's whole queue.
deploy.stagedBytesSCAFO_STAGED_BYTES--staged-bytespositive safe integer5368709120 (5 GiB)Maximum staged deploy bytes on this node.
deploy.applicationStagedBytesSCAFO_APPLICATION_STAGED_BYTES--application-staged-bytespositive safe integer3221225472 (3 GiB)Maximum staged deploy bytes for one application.
deploy.receiveTimeoutMsSCAFO_RECEIVE_TIMEOUT_MS--receive-timeout-mspositive safe integer, ms600000 (10 min)Artifact receive deadline.
deploy.queueTimeoutMsSCAFO_QUEUE_TIMEOUT_MS--queue-timeout-mspositive safe integer, ms600000 (10 min)Queued deploy deadline.
deploy.taskTimeoutMsSCAFO_TASK_TIMEOUT_MS--task-timeout-mspositive safe integer, ms300000 (5 min)Active deploy deadline.
deploy.outputBytesSCAFO_OUTPUT_BYTES--output-bytespositive safe integer65536Subprocess output tail retained in memory.

#observability

File keyEnvFlagTypeDefaultDescription
observability.enabledSCAFO_OBSERVABILITY--observability / --no-observabilitybooleanfalseWhether 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.ingressAccessLogSCAFO_INGRESS_ACCESS_LOG--ingress-access-logfile pathunsetJSON 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 keyEnvFlagTypeDefaultDescription
api.publicSCAFO_API_PUBLIC--api-public / --no-api-publicbooleantrueServe 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.jsonBytesSCAFO_JSON_BYTES--json-bytespositive safe integer65536Maximum 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 keyEnvFlagTypeDefaultDescription
ingress.tokenSCAFO_INGRESS_TOKEN--ingress-tokenlist, primary first; one token in the file is written as a plain stringunset — the node mints one and keeps it in the state directoryEvery 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.entryPointsSCAFO_TRAEFIK_ENTRYPOINTS--traefik-entrypointslistunset, and the published routers attach to webTraefik 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 keyEnvFlagTypeDefaultDescription
transport.driverSCAFO_TRANSPORT--transportwireguard or noneunset, which is only legal for a node that stays on loopbackWhat 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 keyEnvFlagTypeDefaultDescription
wireguard.addressSCAFO_WG_ADDRESS--wg-addressaddress/prefixlenunsetThis node's own address on the tunnel with its prefix length, such as 10.83.0.1/24.
wireguard.endpointSCAFO_WG_ENDPOINT--wg-endpointhost:portunsetWhere peers send this node's tunnel traffic, such as 203.0.113.7:51820.
wireguard.portSCAFO_WG_PORT--wg-portinteger 1–6553551820UDP port the tunnel interface listens on.
Read more in Docs > Wireguard.

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

RuleRefusal
registry.resyncIntervalMs, registry.nodeRefreshIntervalMs, coordinator.leaseTtlMs and coordinator.reapIntervalMs are each a positive safe integerinvalid-input
runtime.portQuarantineMs is a positive safe integerinvalid-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 portsinvalid-input "Invalid port range"
deploy.applicationActiveTasks ≤ deploy.activeTasksinvalid-input "Invalid deploy limits"
deploy.applicationQueuedTasks ≤ deploy.queuedTasksinvalid-input "Invalid deploy limits"
deploy.applicationStagedBytes ≤ deploy.stagedBytesinvalid-input "Invalid deploy limits"
deploy.stagedBytes ≥ deploy.artifactBytesinvalid-input "Invalid deploy byte limits"
deploy.applicationStagedBytes ≥ deploy.artifactBytesinvalid-input "Invalid deploy byte limits"
observability.ingressAccessLog requires observability.enabledinvalid-input "Access log configured with observability off"
ingress.entryPoints, when set, names at least one entryPointinvalid-input "Invalid configuration value"
ingress.token, when set, is a token or a list of them and holds no empty entryinvalid-input "Invalid configuration value"
runtime.celldEnv sets no name the runtime driver derives itselfinvalid-input "Reserved environment variable"
transport.driver is wireguard or noneinvalid-input "Invalid configuration value"
node.listen, node.advertise or runtime.bindHost beyond loopback requires transport to be setinvalid-input "No transport configured"
transport.driver: wireguard requires both wireguard.address and wireguard.endpointinvalid-input "Incomplete WireGuard configuration"
transport.driver: wireguard requires a tunnel keypair in the state directoryinvalid-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.

VariableRead byEffect
XDG_STATE_HOMEthe CLI, when node.stateDir is unset and the process is not uid 0State directory becomes $XDG_STATE_HOME/scafo when the value is absolute; a relative value is ignored.
CELLD_ESBUILDthe deploy runnerPassed 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_PLATFORMthe deploy runnerPassed 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_DIRthe deploy runnerPassed 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_BYTESthe deploy runnerPassed 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_HOSTthe reconciler and the deploy runnerThe 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.

Read more in Config > Celld.

#Files under the state directory

Everything here is owner-readable only (0600), in a directory Scafo creates 0700.

FileWritten byHolds
node.jsonthe daemon, on first bootThis 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 runsThe 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 runsThe static assets that application has downloaded, cached per application rather than shared across the machine.
celld/<application>/ports.jsonthe daemon, when the application first starts hereThe two ports this application holds on this node, reused on every later start.
celld/.released.jsonthe daemon, when an application's local state goesPorts released here and when, so a freed port is not handed to another application while anything may still route to it.
ingress-tokenthe daemon, every bootThe 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.keyscafo wg initThe tunnel private key, as wg genkey writes one. Never sent to the registry.
wg-public.keyscafo wg initThe tunnel public key, as wg pubkey writes one. This is what peers are told, through the node's registry record.
wg0.confscafo wg init and scafo wg peersThe 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:

PathModeContents
/etc/scafo/node.env0600The 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.json0644The 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.

Read more in Deploy > Bare Metal.