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.1SCAFO_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/healthThe 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 listThis 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 ./appThe 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/healthAn 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/countA 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 shopMetrics 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 --registerBoth 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/countThe 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-bA 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.
#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.
#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 shopRun 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.