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

1. the command-line flag, if it was passed;
2. the environment variables listed for it, **first listed wins**;
3. the dotted key in the config file;
4. 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:

1. `./scafo.config.jsonc`
2. `./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.

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

::code-group

```jsonc [scafo.config.jsonc]
{
  // 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,
  },
}
```

```sh [.env]
SCAFO_S3_ENDPOINT=https://store.internal.example.com:8333
SCAFO_S3_REGION=us-east-1
SCAFO_S3_BUCKET=scafo
SCAFO_S3_PREFIX=prod
SCAFO_S3_ACCESS_KEY=AKIAEXAMPLE
SCAFO_S3_SECRET_KEY=s3cr3t-example-key

SCAFO_NODE_ID=node-a
SCAFO_LISTEN=10.83.0.1:7400
SCAFO_ADVERTISE=10.83.0.1:7400
SCAFO_STATE_DIR=/var/lib/scafo
SCAFO_NODE_LABELS=region=eu-west-1,tier=fast

SCAFO_PLATFORM_DOMAINS=apps.example.com

SCAFO_CELLD_BIN=/usr/local/bin/celld
SCAFO_PORT_RANGE_START=24000
SCAFO_PORT_RANGE_END=24998
SCAFO_PORT_QUARANTINE_MS=600000
SCAFO_BIND_HOST=10.83.0.1
SCAFO_CELLD_ENV=CELLD_MAX_RSS_MB=512,CELLD_TTL_MS=30000

SCAFO_REGISTRY_RESYNC_MS=60000
SCAFO_REGISTRY_NODE_REFRESH_MS=30000

SCAFO_COORDINATOR_LEASE_TTL_MS=45000
SCAFO_COORDINATOR_REAP_INTERVAL_MS=600000

SCAFO_ARTIFACT_BYTES=1073741824
SCAFO_ARCHIVE_ENTRIES=20000
SCAFO_ACTIVE_TASKS=2
SCAFO_APPLICATION_ACTIVE_TASKS=1
SCAFO_QUEUED_TASKS=64
SCAFO_APPLICATION_QUEUED_TASKS=4
SCAFO_STAGED_BYTES=5368709120
SCAFO_APPLICATION_STAGED_BYTES=3221225472
SCAFO_RECEIVE_TIMEOUT_MS=600000
SCAFO_QUEUE_TIMEOUT_MS=600000
SCAFO_TASK_TIMEOUT_MS=300000
SCAFO_OUTPUT_BYTES=65536

SCAFO_OBSERVABILITY=1
SCAFO_INGRESS_ACCESS_LOG=/var/log/scafo-ingress/access.log

SCAFO_API_PUBLIC=1
SCAFO_JSON_BYTES=65536

SCAFO_INGRESS_TOKEN=kZ9x-new-token,Qp1v-old-token
SCAFO_TRAEFIK_ENTRYPOINTS=web,websecure

SCAFO_TRANSPORT=wireguard
SCAFO_WG_ADDRESS=10.83.0.1/24
SCAFO_WG_ENDPOINT=203.0.113.7:51820
SCAFO_WG_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](/config/celld#durability-and-the-state-directory). The official image sets `SCAFO_STATE_DIR=/var/lib/scafo`. |
| `node.labels` | `SCAFO_NODE_LABELS` | `--node-labels` | [map](#lists-and-maps) | 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](#lists-and-maps) 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](#lists-and-maps) | 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](/docs/placement#port-pairs).

::read-more{to="/config/celld"}
What Scafo sets on each Celld process itself, and the names it refuses.
::

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

::read-more{to="/docs/registry#how-a-node-follows-the-registry"}
The change hint these two bound.
::

## `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](#lists-and-maps), 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](#lists-and-maps) | 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. |

::read-more{to="/docs/wireguard"}
Setting the tunnel up with `scafo wg init` and `scafo wg peers`.
::

## 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](/docs/containers)'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](/docs/deploying#python-workers)'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](/config/celld#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](/config/celld#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{to="/config/celld"}
Celld runtime policy, the reserved names, and the shutdown-grace arithmetic.
::

## 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](/config/celld#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.

::read-more{to="/deploy/bare-metal"}
The full bare-metal install walkthrough.
::
