Deploy

The prerequisites every Scafo installation shares, the recipe to follow for each kind of host and ingress, and the first deploy that is the same on all of them.

Each recipe runs one or more Scafo nodes against one S3-compatible bucket and puts an ingress in front of them. The ingress pulls: Caddy reads /v1/ingress/routes through the scafo dynamic-upstreams module, Traefik reads /v1/ingress/routes/traefik through its http provider. The node writes no ingress configuration, and nothing but the ingress ever talks to the node's own listener. Operators reach the fleet through the ingress too, at the platform apex.

#Common prerequisites

#Object store

An S3-compatible bucket that supports conditional writes with If-Match and If-None-Match. It is the whole registry: no recipe creates it, and the node refuses to boot without SCAFO_S3_ENDPOINT, SCAFO_S3_BUCKET, SCAFO_S3_ACCESS_KEY and SCAFO_S3_SECRET_KEY.

Prefer a flat-rate or self-hosted store over one billed per request: every runtime process polls the bucket several times a minute whether or not anyone is calling it, and that adds up to tens of millions of requests a month for a small installation. See what an installation costs its bucket.

One bucket-and-prefix pair is one fleet. Every node pointed at the same bucket and the same SCAFO_S3_PREFIX is one installation; nothing else joins two nodes together. Use separate buckets or prefixes for separate installations.

Warning

The bucket is both the durable registry and the availability boundary. It must provide reliable, low-latency conditional writes. Bucket credentials are fleet-root authority: they live on the nodes and in the shell that mints the first operator key, and never reach the ingress.

#DNS

Point an apex record and a wildcard record for the platform domain at the machines that terminate TLS, for example apps.example.com and *.apps.example.com. The same domain goes in SCAFO_PLATFORM_DOMAINS (no scheme, no wildcard); without it, applications get no host.

Do this before the first deploy, not after: the apex is also how the CLI reaches the fleet, and the recipes assume it resolves.

A wildcard DNS record does not imply a wildcard certificate. Whether the platform domain is covered by one wildcard certificate (DNS-01) or by a certificate per host (HTTP-01 or TLS-ALPN, issued on first request) is per recipe.

#Ports

On every host that terminates TLS, open TCP 80 and 443, plus UDP 443 where the recipe's ingress speaks HTTP/3. With the WireGuard transport, also open UDP 51820 between the hosts.

Nothing else is opened, published or forwarded. The node API is not a port of the host in any recipe: see the node API.

#Ingress token

The ingress presents one bearer token to /v1/ingress/routes and /v1/storage on the node's own listener. Generate one per installation, or per host where the recipe says so:

openssl rand -hex 32

It goes to the node as SCAFO_INGRESS_TOKEN and to the ingress through whatever mechanism that ingress has. It is the only credential the ingress ever holds.

Read more in Docs > Security.

#Persistent state

Every recipe keeps the node's state directory (SCAFO_STATE_DIR, /var/lib/scafo in the image) on storage that survives the node being re-created, not only restarted: a named volume, a retained claim, a real host directory. It holds the node's identity and the working directory of every runtime process, and a write is acknowledged to the caller once a peer holds it there, before the bucket has a copy. Starting on an empty one starts without those writes.

The node id has to be stable for the same reason. Neither it nor the directory may change when you move the node to a newer image.

Read more in Config > Celld#durability And The State Directory.

#Shutdown grace

Give the node at least 50 seconds between SIGTERM and SIGKILL, so it can drain its runtimes. If CELLD_SHUTDOWN_TOTAL_MS is raised above its 40-second default, use that value plus at least 10 seconds instead.

In containers this is stop_grace_period: 50s (Compose), --stop-timeout 50 (docker run) or terminationGracePeriodSeconds: 50 (Kubernetes). Under systemd it is TimeoutStopSec=50s.

#Process layout

In a container, the Node entrypoint must stay PID 1 in a private PID namespace. Do not use host PID mode, and do not wrap the entrypoint in a persistent supervisor. On a bare host, use the installer's systemd unit or an equivalent cgroup-owning supervisor that signals the daemon itself rather than every process beside it.

#The node API

The node's own listener, 127.0.0.1:7400 by default, carries everything privileged: deploy intake, operator commands, the routing document and the TLS storage the ingress keeps private keys in. Two parties need it, and both are already next to the node:

  • The ingress, which reads routing and storage from it with the ingress token. A colocated Caddy shares the node's network namespace and dials loopback; a platform's Traefik dials the node over the container network it shares with it.
  • The node itself, whose health probe asks it.

Operators are not a third. The node serves its API at the platform apex through the ingress by default (SCAFO_API_PUBLIC), with /v1/storage and /v1/ingress/routes denied there, so scafo remote add prod --endpoint https://apps.example.com is the operator path on every recipe, from any machine, over the TLS the ingress already terminates. No recipe publishes port 7400 to a host, a loopback included: a listener nothing uses is only a surface, and a host-loopback publish is one edit away from a public one.

Widening the listener beyond loopback is a transport decision: SCAFO_TRANSPORT=none states that the network the node binds is already private and trusted (a container bridge, a pod network), SCAFO_TRANSPORT=wireguard puts node and runtime traffic on a tunnel. The recipes that need one say which and why; the ones that do not leave the defaults alone.

Read more in Docs > Nodes#transports.

#Without the apex

If SCAFO_API_PUBLIC=0 leaves the apex unrouted, or the DNS record does not exist yet, the API is reached only where it listens, through a tunnel you open for the occasion:

RecipeThe API listens onReach it with
Bare metalthe host's loopbackssh -L 7400:127.0.0.1:7400 <host>
VPS meshthe host's tunnel addressssh -L 7400:10.83.0.1:7400 <host>
K3s and RKE2the pod networkkubectl -n scafo port-forward svc/scafo-nodes 7400:7400
Coolify, Dokploythe container's bridge addressadd ports: ["127.0.0.1:7400:7400"] to the node service for the duration, then ssh -L 7400:127.0.0.1:7400 <host>; remove the publish after
Composethe container's loopbacknothing reaches it, a publish included: a published port forwards to the container's bridge address, not its loopback. Set SCAFO_LISTEN: 0.0.0.0:7400 and SCAFO_TRANSPORT: none beside the publish above, for the duration, and revert all three after

Then name the forwarded end on each command, --node http://127.0.0.1:7400, or register it as the remote's endpoint. The forward is yours and goes away with the shell; nothing in the recipe stays open for it.

Read more in Config > Operator#the Endpoint.

#The recipes

RecipeHostsIngressTLS
Docker Composeone host, Dockerscafo-caddy colocated, shared network namespaceon-demand per host the fleet routes
Bare metalone Linux host, systemdscafo-caddy colocated, installer-written uniton-demand per host the fleet routes
VPS meshtwo or more VPSs, WireGuardscafo-caddy per host, -dns flavourDNS-01 wildcard for the platform domain, on-demand for custom domains
K3s and RKE2a clusterthe bundled Traefik, http providercert-manager wildcard, Traefik HTTP-01 for custom domains
Coolifya Coolify serverCoolify's Traefik, http providerCoolify's Let's Encrypt resolver, HTTP-01 per host
Dokploya Dokploy serverDokploy's Traefik, http providerDokploy's letsencrypt resolver, HTTP-01 per host

The container image documents the image the first six recipes run, its tags, and the scafo-caddy images and flavours.

#Choosing by ingress

No ingress on the host yet: run scafo-caddy beside the node. The Compose, bare-metal and VPS recipes take this shape. Caddy holds no bucket credential: it keeps certificates and their locks in the bucket by asking the colocated node over /v1/storage, and reads the fleet's routing from /v1/ingress/routes. Both calls carry the ingress token. The image runs from environment variables alone, and one Caddy can front several fleets. scafo-caddy is the only Caddy build that can do it; upstream Caddy carries none of its scafo modules.

A Traefik already runs in front of everything on this host or cluster: teach it the http provider. The K3s, Coolify and Dokploy recipes take this shape. Traefik polls /v1/ingress/routes/traefik with an Authorization: Bearer header and gets native Traefik dynamic configuration back; its routers arrive as <host>@http beside the platform's own. That needs Traefik v3, because providers.http.headers is what presents the token; Traefik 2.x has no such setting. TLS is then whatever that Traefik already does, and SCAFO_TRAEFIK_ENTRYPOINTS names the entryPoint Scafo's routers attach to.

Note

Both paths poll. A node restart does not reconfigure the ingress: Caddy keeps the last routing document that parsed, and Traefik keeps its previous configuration.

Read more in Docs > Ingress Caddy.
Read more in Docs > Ingress Traefik.

#First deploy

The same on every recipe. The fleet trusts no operator key until somebody mints one, and no node holds one: the first key is minted on your own machine, never on a server or inside a container, and it talks to the fleet through the apex.

#Install the CLI

On your own machine.

npm install --global scafo

#Give the shell the nodes' bucket values

scafo remote add --register writes the key's public half straight into the registry, with no daemon involved, so this one shell needs the same values the nodes run with. Each recipe says where it put them.

export SCAFO_S3_ENDPOINT="https://s3.example.com"
export SCAFO_S3_BUCKET="scafo"
export SCAFO_S3_ACCESS_KEY="<access key>"
export SCAFO_S3_SECRET_KEY="<secret key>"
export SCAFO_S3_REGION="us-east-1"
export SCAFO_S3_PREFIX="scafo"

SCAFO_S3_PREFIX must be the nodes': a different prefix is a different, empty installation.

#Mint and register the first admin key

--endpoint is the apex in SCAFO_PLATFORM_DOMAINS, which the ingress routes to the node API.

scafo remote add prod --endpoint https://apps.example.com --register

The key it minted stays in your operator home, ~/.scafo unless SCAFO_HOME says otherwise. Nothing can reissue it; back it up. Every later command goes through the remote and needs no bucket value.

#Confirm the fleet

scafo fleet nodes

Every node is listed with its CELLD and SCAFO versions. scafo: no node is registered in this bucket; a machine joins the fleet by running "scafo sail" means this shell is reading a different bucket or prefix than the nodes write.

#Deploy

scafo deploy ./my-app --name shop
curl -sS https://shop.apps.example.com/

#Hand it to CI

A job deploys with its provider's OIDC token and no operator key at all, once scafo trust set has written the application's trust policy into the same bucket.

Read more in Docs > Deploying.
Read more in Docs > Operators.