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.
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.
#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:
services:
node:
group_add:
# stat -c %g /var/run/docker.sock
- "998"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.
#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–24998by 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. - The API listener and
runtime.bindHostbind the host's interfaces.0.0.0.0now means every interface of the machine, andSCAFO_TRANSPORT=nonebinds 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:noderuns withnetwork_mode: hostinstead and publishes noports:of its own. - Worker code reaches the host's loopback. See Celld's operator API.
The VPS recipe already runs its node this way. The Compose recipe 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.
{
"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.
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:
# on every node that has an engine
SCAFO_NODE_LABELS=containers=truescafo 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.
#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.
#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 like any other Celld variable.
- Celld containers
- Cloudflare Containers, the standard API behaviour