
# celld

**Use `mountx/s3` as the bucket for a celld fleet.**

[celld](https://github.com/denoland/celld) lets you run Cloudflare Workers and Durable Objects on your own machines. Each Durable Object uses its own SQLite database. celld copies the database data to an S3 bucket. Its nodes also use the bucket to coordinate with each other. It does not need a separate control service.

celld uses only a small part of the S3 API, but conditional writes must work correctly. [`mountx/s3`](/transports/s3) supports these requests and can serve a celld bucket from an `FsDriver`.

::note
This setup was tested in August 2026 with celld 0.2.0 and `mountx/s3` from `main`, after the [conditional `PUT` fix](/transports/s3#conditional-writes). The test used one host, one gateway process, two celld nodes, and a [node-fs](/guide/drivers/built-in#node-fs) driver with a local directory.
::

## What celld needs from a bucket

celld's [fencing document](https://github.com/denoland/celld/blob/main/docs/fencing.md) lists three requirements. Only one node may own a cell at a time. A record in the bucket controls that ownership.

| Requirement                    | Request                         | What `mountx/s3` does                                     |
| ------------------------------ | ------------------------------- | --------------------------------------------------------- |
| Create only if missing         | `PUT` with `If-None-Match: *`   | Uses `O_CREAT\|O_EXCL`; the losing request receives `412` |
| Update only if unchanged       | `PUT` with `If-Match: "<etag>"` | Compares and writes under a per-key `S3Session` lock      |
| Read a completed write at once | `GET` after a successful `PUT`  | Uses the driver's read-after-write behavior               |

celld also sends `ListObjectsV2`, `GET`, ranged `GET`, `PUT`, `HEAD`, and `DELETE` requests. It uses path-style URLs and SigV4 signatures. Its list requests use `prefix` and `start-after`. It does not use multipart uploads.

celld adds `x-amz-meta-litestream-timestamp` to replication segments. The gateway stores only [`x-amz-meta-mtime`](/transports/s3#mtime-not-much-else), but celld does not read the Litestream header back.

::warning
**Use exactly one gateway process for each fleet bucket.** Conditional writes are protected only inside one `S3Session`. If two `createS3Server()` processes use the same directory, each process has its own lock. Two conflicting writes could then both succeed. Point every celld node at the same gateway process.
::

## Setup

First, serve a directory as an S3 bucket:

```ts [gateway.ts]
import { createS3Server } from "mountx/s3";
import { createNodeFsDriver } from "mountx/drivers/node-fs";

await using server = await createS3Server(createNodeFsDriver("./bucket"), {
  bucket: "cells",
  port: 9100,
  credentials: { accessKeyId: "celld", secretAccessKey: "celld-secret" },
  region: "us-east-1",
}).listen();

console.log(server.url); // http://127.0.0.1:9100
```

Pass the same credentials to celld through the standard AWS environment variables. `celld deploy` also needs [esbuild](https://esbuild.github.io) on `PATH`:

```sh
export AWS_ACCESS_KEY_ID=celld AWS_SECRET_ACCESS_KEY=celld-secret AWS_REGION=us-east-1

celld deploy ./my-worker \
  --bucket s3://cells \
  --endpoint http://127.0.0.1:9100 \
  --region us-east-1

CELLD_WATCH=./state celld \
  --bucket s3://cells \
  --endpoint http://127.0.0.1:9100 \
  --region us-east-1 \
  --listen 127.0.0.1:8080 \
  --internal-listen 127.0.0.1:8081 \
  --advertise 127.0.0.1:8081
```

You must set both `--endpoint` and `--region`. The endpoint is not AWS, so celld cannot find the region from it. `CELLD_WATCH` is the node's local working directory. It is not the bucket directory.

## What works

- **Deploy:** `celld deploy` bundles the Worker. It writes `deploy/current.json` and the versioned objects.
- **Activation:** When no node owns a requested cell, one node gets the ownership record and starts the isolate.
- **Replication:** celld copies the cell's SQLite data to the bucket as LTX segments under an epoch prefix.
- **Restore:** You can stop a node, delete its `CELLD_WATCH` directory, and start it again. It restores the cell from the bucket, and the Durable Object keeps its previous state.
- **Two nodes:** The nodes find each other through leases in the bucket. If a node loses the race for a cell, it sends the request to the owner.
- **Fencing:** When nodes race for a new cell, only one becomes the owner. The others receive `412` and follow the winning owner.

## Known problems

### A reader may see an incomplete `PUT`

The gateway writes directly to the target object. It does not write a temporary file and rename it later. See [the write-in-place caveat](/transports/s3#the-write-in-place-caveat). Real S3 does not let readers see a partly written `PUT`, but this gateway can.

celld can hit this problem when a node loses a conditional write and immediately reads the winning record:

```
celld ownership read failed: decode s3://cells/cells/Counter:5a8beec…/own.json:
  EOF while parsing a value at line 1 column 0
→ Worker failed: rejected: Error: route failed: ResolveFailed
```

In one test, two nodes requested 100 different new cells at the same time. This error happened four times. The request failed, but the nodes did not create two owners or different copies of the data. See [issue #20](https://github.com/pithings/mountx/issues/20).

### `celld diagnose` only checks access

The bucket test in `celld diagnose` sends one list request. It can print `ok bucket` even when a bucket does not support conditional writes. Use this result only to confirm that celld can reach the bucket. It does not confirm that the bucket is safe for a fleet.

## Not tested

- More than one gateway process. Do not use this setup because conditional writes are not protected across processes.
- Long runs, many cells, or low-memory conditions.
- Crash safety for the directory used by the driver. The gateway passes writes to the driver and does not call `fsync` itself.
- Drivers other than node-fs. The [memory driver](/guide/drivers/built-in#memory) keeps the whole fleet in RAM and loses it when the process exits, so it is not suitable for a fleet bucket.

## Next

- [S3](/transports/s3): details about [conditional writes](/transports/s3#conditional-writes) and [who may connect](/transports/s3#who-may-connect).
- [Built-in drivers](/guide/drivers/built-in): choose the storage behind the bucket.
- [celld documentation](https://celld.dev/docs): learn how to use celld.
