
# Container classes

> An application may attach a container to a Durable Object class. Celld builds, saves, loads, starts and fences that container; Scafo hands Celld the machine's container engine and nothing else.

## What Scafo does, and what it does not

Container classes are Celld's, end to end. What a node contributes is small and fixed:

| Scafo does | Scafo does not |
|---|---|
| Hand the daemon's `DOCKER_HOST` to every runtime process and to every publish, and its `CELLD_DOCKER` and `CELLD_CONTAINER_PLATFORM` to every publish, unread | Start, stop or inspect a container |
| Ship the engine's `docker` client in the image | Ship, install or probe an engine |
| Collect saved images no kept deployment references, in `scafo deployment prune` | Record anything about engines in a node's record |
| Place applications by the labels and selectors every application has | Know which nodes have an engine, or refuse anything of its own because of one |

There is no switch to turn container classes on. A node given an engine socket has one; a node without one does not, and nothing in Scafo tells the two apart. Every container failure — an image that cannot be built, a container that cannot be started — is Celld's, and is reported in Celld's own words.

The operator keeps container applications on the machines that have an engine, with [node labels and selectors](/docs/placement#selectors).

::warning
Containers are experimental in Celld: the configuration keys, the `ctx.container` surface, the node-side defaults and the security boundary can change without notice. See Celld's [containers documentation](https://github.com/denoland/celld/blob/v0.6.2/docs/services/containers.md).
::

## What a node needs

| Requirement | Why |
|---|---|
| The engine's socket | Celld reaches the engine only through a Unix socket. |
| Access to that socket | The node process has to be allowed to open it. |
| The engine host's network namespace | Celld reaches a container from the host side of the bridges it creates. |

### The socket

Celld uses the Unix socket that `DOCKER_HOST` names when it is a `unix://` URL, and otherwise the default socket of Docker, Docker Desktop, OrbStack or Podman. A `DOCKER_HOST` that is not a `unix://` URL gives Celld no socket at all.

Scafo passes the daemon's own `DOCKER_HOST`, when it is set, to every runtime process it starts and to the `celld deploy` that publishes, and never reads it. Mounting the socket at the default path, `/var/run/docker.sock`, needs no variable at all.

### Access to the socket

The image runs as uid `10001`. Give that user the group that owns the socket on the host, as a supplementary group:

::code-group

```yaml [compose.yaml]
services:
  node:
    group_add:
      # stat -c %g /var/run/docker.sock
      - "998"
```

```sh [docker run]
docker run --group-add "$(stat -c %g /var/run/docker.sock)" ...
```

::

On a bare host the daemon runs as the installer's `scafo` user, and the unit the installer writes grants it no access to a socket. Giving it that access is yours to decide.

::warning
**Mounting the socket is the operator's opt-in, and it makes the node root-equivalent on that machine.** Scafo never changes groups and never mounts anything itself. See [the engine socket](/docs/security#the-engine-socket).
::

### The engine host's network namespace

Celld reaches a container from the host side of the bridges it creates, `celld0` and `celld1`, and fences those bridges with nftables rules that reject whatever they send into a private range. A node in a network namespace of its own — a container on a bridge network, as the Compose recipe runs it — is on the far side of both: its deploy succeeds, its `start()` succeeds, the engine shows the container running, and every `getTcpPort().fetch()` then hangs.

So the node runs in the engine host's own network namespace:

| Host | How |
|---|---|
| The image | `network_mode: host` in Compose, `--network host` for `docker run` |
| Bare metal | Already there: the daemon is a process on the machine |

That changes what the node binds:

- **The runtime port range is the host's.** Each application's two ports are drawn from `runtime.portRangeStart`–`runtime.portRangeEnd` (`24000`–`24998` by default) and bind-probed before use, but on the host network those are the machine's own ports, beside the machine's own services. Size the range to fit it — see [port pairs](/docs/placement#port-pairs).
- **The API listener and `runtime.bindHost` bind the host's interfaces.** `0.0.0.0` now means every interface of the machine, and `SCAFO_TRANSPORT=none` binds the runtime processes there too. On a single host whose ingress runs beside the node, loopback is enough for both.
- **An ingress that shares the node's namespace moves with it.** A Caddy or Traefik started with `network_mode: service:node` runs with `network_mode: host` instead and publishes no `ports:` of its own.
- **Worker code reaches the host's loopback.** See [Celld's operator API](/docs/security#cellds-operator-api).

The [VPS recipe](/deploy/vps) already runs its node this way. The [Compose recipe](/deploy/compose#container-classes) shows the changes to its file.

## Deploying a container class

A container class is declared in the application's own `wrangler.jsonc`: a `containers` entry naming a SQLite-backed Durable Object class of the same script, with its binding and its migration.

```jsonc [wrangler.jsonc]
{
  "name": "sidecar",
  "main": "index.js",
  "compatibility_date": "2025-01-01",
  "containers": [{ "class_name": "Sidecar", "image": "./Dockerfile", "instance_type": "basic" }],
  "durable_objects": { "bindings": [{ "name": "SIDECAR", "class_name": "Sidecar" }] },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Sidecar"] }]
}
```

The node hands this file to Celld byte for byte, as it does every config. The `containers` keys, the instance types and the `ctx.container` surface are Celld's.

### Where the deploy goes

The node that **accepts** a deploy runs `celld deploy`, which builds the image from the Dockerfile, or pulls the image reference, with the `docker` client on its `PATH` — `/usr/local/bin/docker` in the image — and saves it to the bucket as `deploy/images/<key>.tar` under the application's Celld fleet. So that node needs the engine too, and a container deploy is sent to one of those nodes by name, with `--node`.

The client is `docker` on that node's `PATH`, or the one `CELLD_DOCKER` names. The image is built for `linux/amd64` unless `CELLD_CONTAINER_PLATFORM` says otherwise, so a fleet of arm64 machines sets it on the node that accepts container deploys. Both come from the daemon's own environment — see [container engine](/config/celld#container-engine).

Sent to a node without an engine — directly, or through an address that may land on any node of a mixed fleet — the deploy fails as `publish-failed` (502), with Celld's own words about the image it could not build in the detail. Nothing is published.

### Where it is served

The **first** deploy creates the application, so it carries the selector. Label the machines that have an engine, then deploy with a selector that names that label:

```sh
# on every node that has an engine
SCAFO_NODE_LABELS=containers=true
```

```sh
scafo deploy ./sidecar --name sidecar --node <engine node> \
  --node-selector containers=true
```

`--node-labels containers=true` and `node.labels` label a node the same way. The label is yours to choose; Scafo reads it only as a label. Add `--replicas` when not every labelled node should serve the application.

An application created without a selector may be placed on every node. There the Worker serves, but a node without an engine cannot activate a cell of a container class, and says so in Celld's own words. `scafo app selector --name sidecar --set containers=true` narrows an existing application.

::read-more{to="/docs/placement#selectors"}
How selectors, rendezvous hashing and replica counts combine.
::

## Pruning saved images

The saved images are content-addressed and shared: two deployments of one image share one tar, and the `celld-fence` image rides with every deployment that has a container. `scafo deployment prune` collects them the way it collects the asset blobs — by reference, against the `containers[].image` and `fence_image` of every **kept** deployment's manifest, and only once an unreferenced image is older than the same `--older-than` window. A manifest whose image reference it cannot read refuses the whole plan.

::read-more{to="/reference/cli#container-images"}
::

## What stays on the machine

A real container start leaves state on the engine host that is named the same for every Celld on the machine, shared by all of them, and outlives the processes that made it:

| What | Kind |
|---|---|
| `celld`, `celld-internal` | Docker networks |
| `celld0`, `celld1` | Network interfaces, the host side of those bridges |
| `inet celld` | The nftables table that fences them |
| `celld-fence` | The image that installs the fence |
| `celld-image:<key>` | Images loaded from the bucket |

The containers themselves do not outlive their Celld process: a node stop destroys every container of that node. Stopping, retiring or deleting the application removes none of the rest, and neither does anything else Scafo does. Take it down by hand only once no Celld process on the machine is using it.

## What is verified

| Surface | State |
|---|---|
| The image on the host network with the socket mounted, through Compose or `docker run`, on rootful Docker — Docker on Ubuntu, and Docker Desktop on WSL2 | Verified through Scafo, end to end |
| Celld on the machine itself, on rootful Docker or rootful Podman | Verified with Celld alone, never through Scafo |
| The bare-metal daemon, as the installer sets it up | Shares the requirements above, but has not been run with a container class |
| Anything else — rootless engines, socket proxies, a node on a network namespace of its own, Kubernetes | Unsupported until Celld supports it |

## Celld's reference

Everything past the engine is Celld's, and documented there: the `ctx.container` surface, the `containers` keys, instance types and `max_instances`, the isolation boundary and its fence, sleep and wake, and the node-side variables. `CELLD_CONTAINER_RUNTIME` and `CELLD_CONTAINER_DNS` reach every runtime process through [`runtime.celldEnv`](/config/celld#container-engine) like any other Celld variable.

- [Celld containers](https://github.com/denoland/celld/blob/v0.6.2/docs/services/containers.md)
- [Cloudflare Containers](https://developers.cloudflare.com/containers/), the standard API behaviour
