celld

celld documentation

celld is a stateful distributed system. It runs server-side JavaScript on your machines. It keeps all shared data in an S3-compatible or Google Cloud Storage bucket that you own. The JavaScript API is the same API that Cloudflare Workers and Durable Objects supply.

In Cloudflare terms, a cell is a Durable Object: a small server with a name and a private SQLite database. You make one cell for each user, each document, each chat room, or each AI agent. A cell serves HTTP, holds WebSocket connections, sets alarms, and makes outbound connections. Each cell runs on one thread, so two requests to the same cell never run at the same instant. A second request can interleave only while the first awaits, and storage operations are synchronous, so a storage operation never interleaves at all. The data in a cell therefore stays consistent. Cells share no database, and the application divides into cells from the start.

A cell has the same states as a Durable Object. A resident cell is in memory: it is active while it does work, and idle when it waits. celld removes an idle cell from memory. A cell that then keeps its hibernatable WebSocket clients, and stays on its node, is hibernated. A cell that no node holds is inactive. An inactive cell is only an object in the bucket, so it costs almost zero, and every cell starts in this state.

Memory holds nothing across these transitions, so the constructor runs again on the next event. A hibernated cell therefore starts as a cold start does, and only two facts separate the two states: the WebSocket clients stay connected, and the cell stays on its node.

One 8 GB node holds 1,000 resident cells, so one resident cell costs approximately $0.05 each month.

The bucket is the coordinator. There is no membership protocol, no failure detector, and no consensus service. One atomic write to the bucket gives a node the ownership of a cell. Replication sends the SQLite data of each cell to the bucket, and celld does not acknowledge a write before the data is there (RPO=0). The loss of a node therefore cannot lose an acknowledged write. To add a node to the fleet, point the node at the bucket. The ownership and fencing page gives the full mechanism and the exact properties that the bucket must provide.

What do you build with cells

A cell fits a workload that divides into named, stateful units:

Contents

Install

The installer downloads the celld binary. Replication occurs in the celld process. A node does not need an external replicator. If your project contains Worker code, celld deploy needs esbuild. An asset-only project does not need esbuild.

curl -fsSL https://celld.dev/install.sh | sh

If the installer tells you, add ~/.local/bin to PATH. To install one exact release, set CELLD_VERSION to the tag of that release, for example v0.0.1. To go back to a previous release, run the installer again with the tag of that release. The releases are on GitHub. Each release has a GitHub Actions build attestation. To make sure that a downloaded file is correct, run gh attestation verify <asset> --repo denoland/celld.

Configure object storage

For an S3-compatible bucket, celld uses the standard AWS credential chain. For Cloudflare R2, do these steps. Create a bucket. Create an S3 API token that has access to that bucket. Then set these variables:

export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_REGION=auto
export S3_ENDPOINT=https://ACCOUNT_ID.r2.cloudflarestorage.com
export CELLD_BUCKET=s3://YOUR-BUCKET

For Google Cloud Storage, celld uses Application Default Credentials. Create a bucket. Then authenticate with gcloud auth application-default login, or point GOOGLE_APPLICATION_CREDENTIALS at a service-account key that has access to the bucket. Then set the bucket:

export CELLD_BUCKET=gs://YOUR-BUCKET

A gs:// bucket takes no S3_ENDPOINT and no AWS_* credentials, and celld ignores the storage region.

On a Compute Engine instance, celld can use the attached service account. The access scopes of the instance cap this credential, and the default scope permits only storage reads. Create the instance with the cloud-platform scope, so the IAM role of the service account controls the access.

The bucket credentials give full control of the fleet. Keep them safe. The bucket contains the deployments, the SQLite replicas, the ownership records, the node leases, and the peer-authentication secret.

A bucket value can add a key prefix: s3://YOUR-BUCKET/PREFIX. Every object of the fleet then goes below PREFIX/, so two fleets can share one bucket. A bucket value without a prefix keeps the objects at the root of the bucket, therefore an existing fleet does not move its data.

The store must provide conditional writes and read-after-write consistency, because the ownership records depend on them. Amazon S3, Cloudflare R2, Google Cloud Storage, Azure Blob Storage, and Tigris qualify; MinIO (community edition), Backblaze B2, Hetzner, and DigitalOcean Spaces do not. See ownership and fencing for the exact requirements.

Deploy an application

If the project contains Worker code, install esbuild on PATH. Then run celld deploy from an applicable Wrangler project:

git clone https://github.com/denoland/celld
cd celld/examples/counter
celld deploy . \
  --bucket "$CELLD_BUCKET" \
  --endpoint "$S3_ENDPOINT" \
  --region "$AWS_REGION"

celld deploy accepts module Workers, Durable Object bindings, and static assets. An asset project can include a Worker or be asset-only. The asset functions include the assets binding, HTML handling, not-found handling, worker-first routes, _headers, and _redirects. If the Wrangler configuration contains an unknown key, the deploy stops with an error. See the limitations for the current deployment boundary.

Start a node

For local development, the default listener is sufficient:

celld \
  --bucket "$CELLD_BUCKET" \
  --endpoint "$S3_ENDPOINT" \
  --region "$AWS_REGION"

For a fleet node, bind the public and internal listeners separately. The ingress can reach the public listener, and the other nodes can reach the internal listener:

celld \
  --bucket "$CELLD_BUCKET" \
  --endpoint "$S3_ENDPOINT" \
  --region "$AWS_REGION" \
  --listen 0.0.0.0:8080 \
  --internal-listen 10.0.0.12:8081 \
  --advertise node-a.internal:8081

An explicit advertised address requires an explicit internal-listener address. Set both command-line options, or use their equivalent environment variables. celld also rejects an explicit non-loopback public listener without an explicit internal listener. This rule identifies an obsolete one-listener configuration.

celld cannot verify that an advertised hostname or a translated port reaches the internal listener. You must route the advertised address to the internal listener, and you must not route it to the public Worker listener.

Add nodes

Start each node with the same bucket settings. Give each internal listener a different address that the other nodes can reach. Set --advertise to that internal address. The nodes find each other through the leases in the bucket. There is no join command and no fixed membership list.

The bucket supplies discovery and authority. The bucket does not supply network reachability. The peer HTTP protocol has a version, a body signature, an HMAC, a clock limit, and replay protection. celld does not terminate TLS. Put the advertised addresses on a private network that you trust, or on an encrypted overlay such as WireGuard or Tailscale. The internal listener also has an unauthenticated operator API, so do not show it to the public internet. See the security page for the complete boundary.

Shut down and roll out a node

celld shuts a node down gracefully on SIGTERM or SIGINT, and these are the signals systemctl stop, docker stop, and a Kubernetes pod delete send. The /__celld/health path reports the node as unhealthy, so a load balancer stops routing to it. The node answers each new request with a 503 and closes the connection, so a client retries on a healthy node. The node then hands every resident cell to a peer by releasing its ownership, and it finishes the requests already in flight. A cell that serves a request is handed off when that request finishes. A peer takes over each released cell at once, so the node leaves without the takeover gap of an abrupt kill.

The internal listener continues to accept /state requests during the drain. The response reports the occupied, evicting, and restoring values. The public health response identifies the active drain with a 503 status.

The handoff runs at most CELLD_RELEASES releases at the same time, and the default is 128. This bound keeps a node with many cells from flooding the object store at shutdown. CELLD_SHUTDOWN_DRAIN_MS bounds the whole drain, and its default is 25000. The node exits when the handoff and the in-flight requests finish, or when this many milliseconds pass, whichever is first — an idle node exits immediately. You must set it below the stop grace of your orchestrator, such as systemd TimeoutStopSec or Kubernetes terminationGracePeriod, so the orchestrator does not send SIGKILL.

To roll out a new version, use the rolling update of your orchestrator: stop each node with SIGTERM, wait for its replacement to report healthy, then move to the next node. celld has no rollout command, because the health signal lets the orchestrator pace the roll.

The upgrade from v0.1.0 to v0.2.0 must not be a rolling update. Stop every v0.1.0 node, then start the v0.2.0 nodes. Two changes require this: v0.2.0 nodes advertise the internal listener, so ownership records that v0.1.0 nodes wrote name an address that v0.1.0 peers can not follow to a v0.2.0 node; and v0.2.0 compacts replicated data into block objects that a v0.1.0 reader can not restore. A fleet must not mix the two versions.

The internal listener also provides an alpha operator API. /state reports the node state, and POST /shutdown starts the same graceful handoff. The POST /shutdown?handoff=preserve request prepares a clean same-node reload and keeps the ownership records. A release can change this API, so keep the operator tooling and the celld release together.

Diagnose a fleet

celld diagnose reads the node leases in the bucket. Then it sends a probe to each live peer. It does not get a lease. It does not change ownership.

celld diagnose \
  --bucket "$CELLD_BUCKET" \
  --endpoint "$S3_ENDPOINT" \
  --region "$AWS_REGION"

To probe only some nodes, use --peer NODE_ID one or more times. The report identifies expired records, unsafe or incorrect advertised addresses, peers that it cannot reach, authentication failures, and protocol versions that do not agree.

Each node line also shows restoring. This value counts each cold route that holds an activation permit or waits for one. A capacity waiter already holds a permit, so the value counts each cold route once. During a rolling update, you must wait for every node to report restoring=0 before you restart the next node. Therefore, one restart's cold work finishes before the next restart removes more warm capacity.

Environment variables

For the full list, run celld -h. This table shows the primary settings:

variable purpose
CELLD_BUCKET The fleet bucket, and an optional key prefix. The same as --bucket
S3_ENDPOINT The S3-compatible endpoint. The same as --endpoint
AWS_REGION, AWS_DEFAULT_REGION The storage region
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN Explicit AWS credentials. The standard AWS credential chain is also available
GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_SERVICE_ACCOUNT_KEY Google credentials for a gs:// bucket. Application Default Credentials are also available
CELLD_ADDR The public Worker listener. The same as --listen
CELLD_INTERNAL_ADDR The peer and operator listener. The same as --internal-listen
CELLD_ADVERTISE The internal address that peers can reach. The same as --advertise
CELLD_UNSAFE_PUBLIC_ADVERTISE Set to 1 to permit a literal public IP in CELLD_ADVERTISE. This setting does not resolve a DNS name or restrict the internal listener
CELLD_NODE An explicit node-session ID
CELLD_WATCH The local work directory for SQLite and replication
CELLD_ESBUILD The path of the esbuild executable
CELLD_ACTIVATIONS The limit for concurrent cold-cell activations (default: the available CPU count or 128, whichever is smaller)
CELLD_OPERATION_DEADLINE_MS The deadline for a non-restore operation (default: 15000)
CELLD_WORKER_LOADER Bind a Worker Loader (Code Mode) at this env name. A Worker can then start isolates at runtime. Off unless set (experimental)
CELLD_MAX_LOADED_WORKERS The limit for concurrent loaded workers (default: 256)
CELLD_MAX_RESIDENT_CELLS The hard limit for resident cells, enforced at admission
CELLD_MAX_RSS_MB The memory threshold for pressure shedding (default: 80% of the available memory; 0 disables it)
CELLD_OUTPUT_GATE The default is 1, so celld proves each write durable before it acknowledges the write. Set 0 to remove the replication wait and accept possible loss of an acknowledged write
CELLD_LTX_COMPACTION The default is 1: celld creates additive L1 objects, and a takeover reads tens of objects instead of thousands. Set 0 on every node of a mixed fleet until all nodes can read v0.5.2 block objects, because an old reader cannot take over a cell after its first L1 publication
CELLD_LTX_COMPACTION_MIN_TXIDS The durable TXID distance that queues an L1 attempt (default: 256)
CELLD_LTX_COMPACTIONS The node-wide limit for concurrent L1 attempts (default: 2)
CELLD_VAR_*, CELLD_VARS_FILE Worker variable overrides
RUST_LOG The runtime log filter

The help output also shows the advanced tuning switches and their defaults.

An unset variable selects its documented default. A Boolean variable accepts only 0 or 1. celld exits during startup when a supplied value is invalid.