Quick start

Run a disposable Scafo installation locally with the playgrounds, then move to a real fleet.

The playgrounds are self-contained Docker Compose stacks in the scafo repository. They use a SeaweedFS container as the S3-compatible store and mock *.scafo.test hosts, so no DNS, cloud account or certificate authority is involved. They run over plain HTTP and omit custom domains and CI deploys.

#Prerequisites

Docker with Compose v2.

git clone https://github.com/sandros94/scafo.git
cd scafo/playgrounds
docker pull ghcr.io/sandros94/scafo:latest
docker pull ghcr.io/sandros94/scafo-caddy:0.2.1

SCAFO_IMAGE and SCAFO_CADDY_IMAGE in .env override either image.

#Single-node playground

One bucket, one node, one Caddy. Ports: 80 for the ingress, 7400 for the node API (loopback), 8333 for the bucket (loopback). Change any of them in .env.

#Start the stack

cd single-node
cp .env.example .env
docker compose up -d
docker compose logs node
curl -s http://127.0.0.1:7400/.well-known/scafo/health

The bucket container creates the bucket and exits successfully. The node then reports its id and the coordinator lease. The health document describes the daemon only; it does not say an application is deployed.

#Point your shell at it

export SCAFO_HOME="$PWD/.scafo"
set -a; . ./.env; set +a
alias scafo='docker compose run --rm cli'

SCAFO_HOME keeps the playground's keys and remotes out of ~/.scafo. With a built checkout, use alias scafo='node ../../dist/cli/index.mjs' and http://127.0.0.1:7400 instead of the container endpoint.

#Register the remote

scafo remote add playground --endpoint http://node:7400 --register
scafo remote list

This mints an admin-tier operator key under .scafo/ and registers its public half in the registry. Later commands resolve the only remote automatically.

#Deploy the application

app/ holds a small Worker with / and a durable /count endpoint. Its wrangler.jsonc names it shop, which is the application the deploy creates and the name it is served under; --name overrides that, and --env preview would address shop-preview instead.

scafo deploy ./app

The command moves through accepted, uploading and deployed, then prints the deployment's version. The node bundles the Worker entry with esbuild first; --no-bundle skips that for an entry that is already self-contained.

#Reach the application

Nothing resolves *.scafo.test, so the requests supply Host explicitly.

curl -H 'Host: shop.scafo.test' http://127.0.0.1/
curl -H 'Host: shop.scafo.test' http://127.0.0.1/count
curl -H 'Host: shop.scafo.test' http://127.0.0.1/.well-known/celld/health

An application is served at one name, <name>.scafo.test. The counter increases across requests. The last response is Celld's application health endpoint, distinct from /.well-known/scafo/health on the node/API listener.

#Inspect the routing document

curl -s -H "Authorization: Bearer $SCAFO_INGRESS_TOKEN" \
  'http://127.0.0.1:7400/v1/ingress/routes?prefer=self'
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: nothing.scafo.test' http://127.0.0.1/

That document is what Caddy polls. An unclaimed host resolves to no upstream and the error route answers 404; there is no wildcard fallback.

#Check the API apex boundary

curl -s -H 'Host: scafo.test' http://127.0.0.1/.well-known/scafo/health
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: scafo.test' \
  'http://127.0.0.1/v1/storage/list?prefix='
curl -s -H "Authorization: Bearer $SCAFO_INGRESS_TOKEN" \
  'http://127.0.0.1:7400/v1/storage/list?prefix='

The first request reaches the node API through the platform apex. The second is refused by Caddy itself: /v1/storage and /v1/ingress/routes are never published on a platform host. The same route stays available on the node's own listener with the ingress token.

#Replace the deployment

Change APP_VERSION in app/wrangler.jsonc from v1 to v2, then:

scafo deploy ./app
curl -H 'Host: shop.scafo.test' http://127.0.0.1/
curl -H 'Host: shop.scafo.test' http://127.0.0.1/count

A different deployment version is printed and the host serves v2. The durable counter continues, because the application's Celld fleet did not change.

#Read observations

scafo metrics --since 30m
scafo traces --since 30m --name shop

Metrics come from the ingress access log and flush every 60 seconds. scafo traces shows non-periodic node events; --all also includes reconcile passes.

#Tear it down

docker compose down -v
rm -rf .env .scafo

#Multi-node playground

Two nodes, one bucket, one application. Ports: 8081/8082 for the two ingresses, 7401/7402 for the two node APIs (loopback), 8333 for the bucket. Complete the single-node playground first.

#Start both nodes

cd ../multi-node
cp .env.example .env
docker compose up -d
curl -s http://127.0.0.1:7401/.well-known/scafo/health
curl -s http://127.0.0.1:7402/.well-known/scafo/health

#Register one remote for both

export SCAFO_HOME="$PWD/.scafo"
set -a; . ./.env; set +a
alias scafo='docker compose run --rm cli'
scafo remote add playground --endpoint http://node-a:7400 --register

Both nodes trust the key, because both read the same registry.

#Inspect membership and coordination

curl -s "$SCAFO_S3_ENDPOINT/scafo?list-type=2&prefix=playground/" | tr '<' '\n' | grep '^Key>'
curl -s "$SCAFO_S3_ENDPOINT/scafo/playground/_scafo/nodes/node-a.json"
curl -s "$SCAFO_S3_ENDPOINT/scafo/playground/_scafo/coordinator/lease.json"

The bucket holds separate node and operator keyspaces plus one coordinator lease. The shared bucket and key prefix are what define the installation.

#Deploy to every node

scafo deploy ./app --replicas all
curl -H 'Host: shop.scafo.test' http://127.0.0.1:8081/
curl -H 'Host: shop.scafo.test' http://127.0.0.1:8082/

Any node accepts the deploy request; coordinator-only registry work is forwarded to the lease holder.

#Observe one cell owner across two machines

curl -H 'Host: shop.scafo.test' http://127.0.0.1:8081/count
curl -H 'Host: shop.scafo.test' http://127.0.0.1:8082/count
curl -H 'Host: shop.scafo.test' http://127.0.0.1:8081/count

The responses form one sequence: Celld owns the durable cell on one runtime node and forwards peer traffic internally. This requires runtime.bindHost beyond loopback and a declared transport.

#Place an application on one node

scafo deploy ./app --env solo --replicas 1
curl -s "$SCAFO_S3_ENDPOINT/scafo/playground/_scafo/placement/shop-solo.json"
curl -H 'Host: shop-solo.scafo.test' http://127.0.0.1:8081/
curl -H 'Host: shop-solo.scafo.test' http://127.0.0.1:8082/

The placement record names one node, but both ingresses answer: every node renders the same fleet-wide routing, and prefer=self only reorders upstreams. The node that does not hold the process forwards to the holder's advertised address.

#Drain a node and restore it

docker compose stop node-b
curl -H 'Host: shop.scafo.test' http://127.0.0.1:8081/
curl -s "$SCAFO_S3_ENDPOINT/scafo/playground/_scafo/placement/shop-solo.json"

docker compose start node-b
curl -s http://127.0.0.1:7402/.well-known/scafo/health
docker compose restart node-b caddy-b

A graceful stop marks the node draining, so placement moves promptly. An uncatchable death must age past the liveness window first. Restart a node and its ingress together: an ingress using network_mode: service:node-b otherwise stays attached to the old network namespace.

#Tear it down

docker compose down -v
rm -rf .env .scafo

::

#First real deploy

Moving from a playground to a real installation means four decisions, in this order:

#Provide a bucket

Create a bucket on an S3-compatible store that honours If-Match and If-None-Match. Use one bucket and prefix pair for one fleet only. The first node to boot initializes the registry and proves the store's conditional writes.

Pick a flat-rate or self-hosted store rather than one billed per request — an idle installation still asks its bucket for a lot, and the numbers are here.

#Install and configure a node

Install with npm, the Linux installer, the container image, or a compiled binary, then give the node its bucket credentials, platform domains, listener and transport.

Read more in Docs > Install.
Read more in Config.

#Put an ingress in front

Choose Caddy with the scafo-ingress modules, or Traefik v3 with its http provider. The ingress receives the ingress bearer token and nothing else. The scafo-caddy image needs no configuration file: two environment variables, the node's API address and that token, are the whole setup for one fleet, and one Caddy can front several fleets the same way.

Read more in Deploy.

#Mint an operator key and deploy

scafo operator keygen --register
scafo remote add prod --endpoint https://node.example.com --register
scafo deploy ./my-app --name shop

Run these on your own machine, not on the node: --register talks to no daemon, so that shell needs the same SCAFO_S3_* values the node has.

Read more in Docs > Deploying.