
# 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](https://github.com/sandros94/scafo). 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.

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

::steps

### Start the stack

```sh
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

```sh
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

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

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

```sh
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

```sh
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

```sh
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:

```sh
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

```sh
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

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

::steps

### Start both nodes

```sh
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

```sh
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

```sh
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

```sh
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

```sh
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

```sh
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

```sh
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

```sh
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:

::steps

### 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](/docs/operations#what-an-installation-costs-its-bucket).

### 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{to="/docs/install"}
Installation methods and what each one writes.
::

::read-more{to="/config"}
Every setting, its flag, its environment variable and its config-file key.
::

### 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{to="/deploy"}
Recipes for Compose, bare metal, VPS meshes, K3s, Coolify and Dokploy.
::

### Mint an operator key and deploy

```sh
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{to="/docs/deploying"}
Deploy flags, admission bounds, watching status and OIDC deploys.
::

::
