
# Container images

> What `ghcr.io/sandros94/scafo` contains and how to run it, plus the `scafo-caddy` ingress images and their two flavours.

## `ghcr.io/sandros94/scafo`

### Tags

| Tag | Published by |
|---|---|
| `:X.Y.Z` | A release, tagged with the bare semver; the git tag's `v` is dropped. |
| `:latest` | The most recent release that is not a prerelease. A prerelease never moves this pointer. |
| `:edge` | Every commit on `main` that passed the full test suite. |
| `:sha-<7>` | The same commit, by its short SHA. |

`:X.Y.Z` and `:sha-<7>` each name one build; `:latest` and `:edge` are pointers that move.

### What it bundles

- `node:24-slim` as the base, plus `ca-certificates`, because the slim base ships no root certificate store.
- Celld 0.6.2 at `/usr/local/bin/celld`, copied from `ghcr.io/denoland/celld:0.6.2@sha256:b85983dcf410bcf710bbc30f400d8654123cb2b541ca4d84d7e299009a6ee97a`, which is how the Dockerfile pins it: by tag and by multi-architecture digest.
- The platform-specific esbuild binary at `/usr/local/bin/esbuild`, with `ENV CELLD_ESBUILD=/usr/local/bin/esbuild` so Celld source builds reach it. Nodes need esbuild only to bundle Worker entries; `scafo deploy --no-bundle` ships a pre-bundled entry and skips it.
- The container engine's `docker` client at `/usr/local/bin/docker`, from Docker's static release 29.8.1, pinned by release and by each architecture's SHA-256. It is the client and no engine: it reaches one only through a socket you mount, and grants the node nothing without one. `celld deploy` builds or pulls a [container class](/docs/containers)'s image through it on the node that accepts the deploy.
- The built application at `/app`, with `dist/`, its production `node_modules`, and `package.json`, which the daemon reads its version from at runtime.

### Runtime settings

```dockerfile
ENV SCAFO_STATE_DIR=/var/lib/scafo
USER scafo
EXPOSE 7400
HEALTHCHECK --interval=5s --timeout=3s --start-period=30s --retries=3 CMD ["node", "/app/dist/cli/index.mjs", "health"]
STOPSIGNAL SIGTERM
ENTRYPOINT ["node", "/app/dist/cli/index.mjs"]
CMD ["sail"]
```

- **User.** `scafo`, uid and gid **10001**, a non-login user whose home is `/var/lib/scafo`, created mode `0700`. Anything bind-mounted there must be owned by 10001. A node given the container engine's socket also needs the socket's group as a supplementary one, `group_add` in Compose or `--group-add` for `docker run`; see [container classes](/docs/containers#access-to-the-socket).
- **State.** `SCAFO_STATE_DIR=/var/lib/scafo` holds the node identity, the ingress token, the working directory and ports of every runtime process this node supervises and, on a WireGuard node, the tunnel keys. It must be on storage that survives the container being **re-created**, not only restarted. The image declares no `VOLUME`, so nothing is kept unless you mount something there.
- **Port.** `EXPOSE 7400` only documents the listener; it publishes nothing. The default bind is `127.0.0.1:7400`, and a wider one is a `SCAFO_TRANSPORT` decision. No recipe publishes this port to a host: the ingress is beside the node, in its network namespace or on its container network, and operators reach the API at the platform apex. See [the node API](/deploy#the-node-api).
- **Healthcheck.** It runs `scafo health`, which reads the same environment and config file the daemon boots from and asks whatever they say the node bound, so it follows the listener onto a tunnel address or anywhere else. A listener chosen with a `sail --listen` flag is invisible to it: set `SCAFO_LISTEN` or `node.listen` instead.
- **Entrypoint.** The entrypoint is the CLI, and `sail` is only the default command. Any other argument list runs that subcommand instead.

### Running other commands

Because the entrypoint is the CLI, the image is also how a host without a Scafo binary runs one-off commands. Rendering a WireGuard interface, as the [VPS recipe](/deploy/vps) does:

```sh
docker run --rm --network host --env-file /etc/scafo/node.env \
  -v /var/lib/scafo:/var/lib/scafo \
  ghcr.io/sandros94/scafo:X.Y.Z wg init --stdout > /etc/wireguard/wg0.conf
```

```sh
docker run --rm --network host --env-file /etc/scafo/node.env \
  -v /var/lib/scafo:/var/lib/scafo \
  ghcr.io/sandros94/scafo:X.Y.Z wg peers --stdout
```

The same shape works for `health`, `fleet nodes`, `metrics` and the rest. Give the container the state directory whenever the command needs the node's identity or keys, and the bucket settings whenever it reads the registry.

Operator ceremonies are not among them. `scafo operator keygen --register`, `scafo remote add --register` and `scafo trust set` want the bucket values and a persistent `SCAFO_HOME`, and the key file they write has to end up on the operator's own machine; a container that exits takes it with it. Install the CLI where you work and mint the first key there: [first deploy](/deploy#first-deploy).

### Volumes to persist

| Path | Why |
|---|---|
| `/var/lib/scafo` | Node identity, ingress token, tunnel keys, and the working directory and ports of every runtime process. Losing it gives the node a new identity and loses writes this fleet has already acknowledged, because a write is acknowledged once a peer node holds it on its disk, before the bucket has it. |
| `/var/log/scafo-ingress` | Only when `SCAFO_INGRESS_ACCESS_LOG` is set: the directory the ingress writes its JSON access log into and the node tails. Share it with the ingress container. |

Use a named volume, a retained claim or a real host directory: something that outlives the container being re-created, not only restarted.

Deployments, builds, placements, certificates and observations are in the bucket, not on the host. The most recent writes of a running application may not be yet.

::read-more{to="/config/celld#durability-and-the-state-directory"}
Why the state directory is where an acknowledged write lives first.
::

### Stopping

`STOPSIGNAL SIGTERM`, and the node drains its runtimes on it. It must be the process that receives the signal, and it needs at least 50 seconds before `SIGKILL`: the [shutdown grace](/deploy#shutdown-grace) and [process layout](/deploy#process-layout) every recipe follows.

### Other build targets

The Dockerfile also carries a `probe` target: the same Celld and esbuild the image ships, with no application, for suites that bind-mount a worktree's build into a container. It is not part of the default build.

```sh
docker build --target probe -t scafo-probe .
```

## `ghcr.io/sandros94/scafo-caddy`

Caddy with the modules Scafo's Caddy path relies on: the `scafo` app, which says where each fleet's node is and which token opens it; `caddy.storage.scafo`, which keeps certificates and private keys in each fleet's bucket through that fleet's node; `http.reverse_proxy.upstreams.scafo`, which resolves each request's host from the routing every node serves at `/v1/ingress/routes`; `tls.permission.scafo`, which decides which names get a certificate on demand; and `http.matchers.scafo_plain_http`. Upstream Caddy carries none of them. The image runs from environment variables alone, `SCAFO_FLEET_ENDPOINT` and `SCAFO_FLEET_TOKEN` for one fleet.

### Flavours

**Lean** is Caddy with only those modules. **`-dns`** is the same build with a curated set of DNS-01 provider plugins compiled in, for a wildcard certificate over the platform domain: the ACME DNS challenge needs a plugin for the zone's provider, and a Caddy plugin can only arrive at compile time.

| | Image | Release binaries |
|---|---|---|
| Lean | `:latest`, `:X.Y.Z`, `:edge` | `scafo-caddy-linux-{amd64,arm64}` |
| With providers | `:latest-dns`, `:X.Y.Z-dns`, `:edge-dns` | `scafo-caddy-dns-linux-{amd64,arm64}` |

Images are published for `linux/amd64` and `linux/arm64`. Each release binary ships a `.sha256` alongside it and is the same binary that is inside the image of its flavour, lifted out of that build's builder stage. Every release also carries one `caddy-version` file naming the Caddy both flavours embed.

The lean flavour covers everything but DNS-01: HTTP-01 needs no plugin, and neither does the on-demand TLS that covers customer domains.

### DNS providers in `-dns`

Bunny, Cloudflare, DigitalOcean, Hetzner, OVH and Route 53: the [caddy-dns](https://github.com/caddy-dns) module for each, pinned to an exact version in the repository's `dns-providers.txt`.

```sh
docker pull ghcr.io/sandros94/scafo-caddy:latest-dns
caddy list-modules | grep -E 'scafo|dns.providers'
# caddy.storage.scafo
# dns.providers.bunny
# dns.providers.cloudflare
# dns.providers.digitalocean
# dns.providers.hetzner
# dns.providers.ovh
# dns.providers.route53
# http.matchers.scafo_plain_http
# http.reverse_proxy.upstreams.scafo
# scafo
# tls.permission.scafo
```

A provider outside that list needs its own build: `xcaddy build --with github.com/sandros94/scafo-ingress --with <module>@<version>`, or the repository's Dockerfile with its `DNS_PROVIDERS` build argument.

::read-more{to="/reference/caddy-modules"}
::
