Skip to content

A Durable Object with celld

Durable Objects on a VM you own. Same code, your bucket.

Deploy on exe.dev

Shelley handles the setup. About 15 minutes.

Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/celld/prompt.txt | ssh exe.dev new --prompt=/dev/stdin

celld runs Cloudflare Workers and Durable Objects on machines you control. A Durable Object is a tiny server with a name and its own SQLite database: one per chat room, document, user, or AI agent. celld is open source, from Deno, and saves every object's database to a bucket you own, so the VM itself is replaceable.

Your VM gets a chat, one Durable Object per room, written exactly as you would for Cloudflare.

What you get#

  • celld 0.6.2 serving a chat at https://<vm>.exe.xyz/, one Durable Object per room.
  • Every write in your bucket before celld answers, with the bucket's keys in one private file.
  • A message that survived restarting celld with an empty local cache.
  • celld starting at boot and coming back after a crash.
  • Nightly snapshots of the bucket, and one restored to prove it works.
What's running, and where
Private https://my-celld.exe.xyz/ The chat, or whatever you deploy next
my-celld.exe.xyz exe.dev VM
celld 0.6 Workers and Durable Objects, managed by systemd :8000~/celld/state
Chat A Worker with one Durable Object per room ~/chat
Your bucket Every object's database, saved before celld answers R2, S3, or Tigris
Nightly snapshots A copy of the bucket on the VM; seven kept ~/celld/snapshots

Deploy with Shelley#

Click Deploy and Shelley will set up celld on a new VM, then check that it works. No config-file wrangling required.

Have these ready; Shelley asks for them:

  • CELLD_BUCKET: an empty bucket, written s3://name (get one)
  • S3_ENDPOINT: R2: https://<ACCOUNT_ID>.r2.cloudflarestorage.com; Tigris: https://t3.storage.dev; Amazon S3: leave empty
  • AWS_REGION: auto for R2 and Tigris; the bucket's region for Amazon S3
  • AWS_ACCESS_KEY_ID: a key that can read and write that bucket, and nothing else
  • AWS_SECRET_ACCESS_KEY: the key's secret
Prefer a terminal?
curl -fsSL \
  https://exe-staging.dev/deploy/celld/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin
What Shelley is told
CELLD_BUCKET=<fill-this-in>
S3_ENDPOINT=<fill-this-in>
AWS_REGION=<fill-this-in>
AWS_ACCESS_KEY_ID=<fill-this-in>
AWS_SECRET_ACCESS_KEY=<fill-this-in>

Set up celld on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/celld.md from top to bottom.

The values at the top of this message are mine:
- CELLD_BUCKET: an empty bucket, written `s3://name` ([get one](#get-a-bucket))
- S3_ENDPOINT: R2: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`; Tigris: `https://t3.storage.dev`; Amazon S3: leave empty
- AWS_REGION: `auto` for R2 and Tigris; the bucket's region for Amazon S3
- AWS_ACCESS_KEY_ID: a key that can read and write that bucket, and nothing else
- AWS_SECRET_ACCESS_KEY: the key's secret
Ask me for any that still say <fill-this-in> before you need them.

The recipe's example VM name is my-celld. This VM's name is the output of `hostname`: use it wherever the recipe writes my-celld. Skip the step that creates the VM; you are on it.

You are done when all of these are true. Check each one yourself:
- celld 0.6.2 serving a chat at `https://<vm>.exe.xyz/`, one Durable Object per room.
- Every write in your bucket before celld answers, with the bucket's keys in one private file.
- A message that survived restarting celld with an empty local cache.
- celld starting at boot and coming back after a crash.
- Nightly snapshots of the bucket, and one restored to prove it works.

Read the whole recipe, including the closed details. Follow "Install by
hand" first, skipping VM creation; the walkthrough is for the owner
afterwards. Do not run the later Restore or Update instructions.
Write the owner's five values into ~/celld/celld.env and never print the
secret key. Never overwrite an existing celld.env. The bucket must be
empty: if `~/celld/run rclone lsf celld:` lists anything, stop and ask
the owner, because celld treats everything in its bucket as its own.
Keep the VM private; do not run share set-public.
Check the health endpoint, a posted message, the same message after
restarting celld with an empty ~/celld/state, that celld.service and
celld-snapshot.timer are enabled, and the snapshot restore test.
Finish with https://<vm>.exe.xyz/, using this VM's name, and tell the
owner to edit ~/chat and run ~/celld/deploy ~/chat to ship changes.

Finish with the URLs I should open.

Also at /deploy/celld/prompt.txt. The recipe it follows is /deploy/celld.md.

Get a bucket#

celld keeps your objects in S3-compatible storage; its own tests run on Cloudflare R2, which has a free tier. In the Cloudflare dashboard:

  1. R2 object storage → Create bucket. Pick a location near your VM.
  2. Back on Overview, choose Manage next to API Tokens and create a token with Object Read & Write for that bucket only.
  3. Copy the Access Key ID, the Secret Access Key, and the S3 endpoint, https://<ACCOUNT_ID>.r2.cloudflarestorage.com, without the bucket name on the end.

Give those to Shelley when it asks. Use a new, empty bucket: celld treats everything in it as its own. Amazon S3 and Tigris work too; see the FAQ.

Say hello#

Open https://my-celld.exe.xyz/ and sign in to exe.dev. Open it again in a second tab and chat with yourself. Reload: the messages are still there.

For another room, add a name to the address, such as ?room=plans. Each room is its own Durable Object, with its own database and its own WebSocket clients.

Make it yours#

The app lives in ~/chat on the VM: src/worker.js (the Worker and its Room class), public/index.html, and the wrangler.jsonc Wrangler would use. Change something, then ship it, on the VM:

~/celld/deploy ~/chat

celld switches to the new code without a restart, and rooms keep their messages. To try a change first, run celld dev --host 0.0.0.0 --port 9876 in ~/chat and open https://my-celld.exe.xyz:9876/. It rebuilds as you edit and keeps a separate scratch copy of the data; Ctrl-C stops it.

Or ask Shelley: "Make rooms forget messages older than a day, using an alarm." Deploying another Wrangler project works the same way, but celld runs one app at a time, so it replaces the chat.

Share it#

On your computer, open the chat to everyone:

ssh exe.dev share set-public my-celld

Anyone with the link can now read and post. ssh exe.dev share set-private my-celld closes it again. Want your own domain? See custom domains.

Keep your data#

Every message is in your bucket before celld answers. The VM keeps only a working copy, so a crash, a reboot, or a brand-new VM loses nothing.

Two things matter: the bucket, and ~/celld/celld.env, which holds its keys. Keep a copy of that file in your password manager. Each night, at a random time between 03:30 and 04:00 UTC, the VM also snapshots the bucket into ~/celld/snapshots, pausing celld for about 20 seconds. To copy them off the VM, on your computer:

scp -r my-celld.exe.xyz:celld/snapshots .

Something off? On the VM, journalctl -u celld -n 50 shows celld's warnings and anything your code logs.

The nuts and bolts#

Happy clicking Deploy? You can stop here. These are the setup steps Shelley follows, plus the commands for later.

Install by hand

Create a VM#

On your computer (pick another name if my-celld is taken):

ssh exe.dev new --name=my-celld
ssh my-celld.exe.xyz

All remaining installation commands run on the VM, as exedev.

Install celld#

celld is one binary. Its installer keeps each release under ~/.local/lib/celld and links the current one into ~/.local/bin. celld deploy bundles Workers with esbuild, and snapshots use rclone:

curl -fsSL https://celld.dev/install.sh | CELLD_VERSION=v0.6.2 sh
(cd /tmp && curl -fsSL https://esbuild.github.io/dl/v0.28.2 | sh && mv esbuild ~/.local/bin/)
command -v rclone >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y rclone; }
celld --version && esbuild --version && rclone version | head -n 1

Expect celld 0.6.2, 0.28.2, and an rclone version.

Connect the bucket#

Create the settings file once, readable only by you. This leaves an existing file alone:

mkdir -p ~/celld/state ~/celld/snapshots
cd ~/celld
if [ ! -e celld.env ]; then
  touch celld.env && chmod 600 celld.env
  cat > celld.env <<'EOF'
# celld's settings. These keys control all of your data: keep a copy of
# this file somewhere safe, such as your password manager.
CELLD_BUCKET=s3://my-celld-bucket
S3_ENDPOINT=https://ACCOUNT_ID.r2.cloudflarestorage.com
AWS_REGION=auto
AWS_ACCESS_KEY_ID=paste-the-access-key-id
AWS_SECRET_ACCESS_KEY=paste-the-secret-access-key
# One node: no peers to replicate to or balance with. Remove these two
# lines when you add a second node.
CELLD_DURABILITY=bucket
CELLD_REBALANCE_INTERVAL_MS=0
# The node's working copy of your objects; the bucket has the rest.
CELLD_WATCH=/home/exedev/celld/state
# celld's warnings, plus everything your code prints with console.log.
RUST_LOG=warn,cell_console=info
EOF
fi
nano celld.env

Replace the first five values with your bucket's. For Amazon S3, delete the S3_ENDPOINT line and set AWS_REGION to the bucket's region.

Add a helper that runs any command with these settings. It also names the bucket celld: for rclone:

cat > ~/celld/run <<'EOF'
#!/bin/sh
# Run a command with celld's settings: ~/celld/run celld cell list
# The bucket is also the rclone remote "celld:": ~/celld/run rclone lsf celld:
set -a
. "$HOME/celld/celld.env"
set +a
export RCLONE_CONFIG=/dev/null RCLONE_CONFIG_S3_TYPE=s3 RCLONE_CONFIG_S3_PROVIDER=AWS \
  RCLONE_CONFIG_S3_REGION="$AWS_REGION" RCLONE_CONFIG_S3_NO_CHECK_BUCKET=true \
  RCLONE_CONFIG_S3_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" \
  RCLONE_CONFIG_S3_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
  RCLONE_CONFIG_CELLD_TYPE=alias RCLONE_CONFIG_CELLD_REMOTE="s3:${CELLD_BUCKET#s3://}"
case "${S3_ENDPOINT:-}" in
  "") ;;
  *.r2.cloudflarestorage.com*) export RCLONE_CONFIG_S3_PROVIDER=Cloudflare RCLONE_CONFIG_S3_ENDPOINT="$S3_ENDPOINT" ;;
  *) export RCLONE_CONFIG_S3_PROVIDER=Other RCLONE_CONFIG_S3_ENDPOINT="$S3_ENDPOINT" ;;
esac
exec "$@"
EOF
chmod +x ~/celld/run
~/celld/run celld diagnose
~/celld/run rclone lsf celld: | head -n 3

Expect ok bucket conditional write: create, reject-create, update, reject-stale, then nothing at all from rclone: the bucket is empty. If rclone lists files, use another bucket.

Write the chat#

Three files, in the same shape as any Wrangler project:

mkdir -p ~/chat/src ~/chat/public
cat > ~/chat/wrangler.jsonc <<'EOF'
{
  "name": "chat",
  "main": "src/worker.js",
  "compatibility_date": "2026-10-01",
  "assets": { "directory": "./public" },
  "durable_objects": {
    "bindings": [{ "name": "ROOMS", "class_name": "Room" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }]
}
EOF

The Worker sends each room's API and WebSocket requests to the room's Durable Object, found by name:

cat > ~/chat/src/worker.js <<'EOF'
import { DurableObject } from "cloudflare:workers";

// One Room per chat room: its own SQLite database and its own WebSockets.
export class Room extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS messages (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      author TEXT NOT NULL,
      text TEXT NOT NULL,
      sent_at INTEGER NOT NULL
    )`);
  }

  // The latest messages, oldest first.
  history(limit = 50) {
    return this.ctx.storage.sql
      .exec("SELECT author, text, sent_at FROM messages ORDER BY id DESC LIMIT ?", limit)
      .toArray()
      .reverse();
  }

  // Save a message and send it to everyone in the room.
  post(author, text) {
    author = String(author ?? "").trim().slice(0, 40) || "anonymous";
    text = String(text ?? "").trim().slice(0, 2000);
    if (!text) throw new Error("empty message");
    const message = { author, text, sent_at: Date.now() };
    this.ctx.storage.sql.exec(
      "INSERT INTO messages (author, text, sent_at) VALUES (?, ?, ?)",
      message.author, message.text, message.sent_at,
    );
    const frame = JSON.stringify({ type: "message", message });
    for (const ws of this.ctx.getWebSockets()) {
      try { ws.send(frame); } catch {}
    }
    return message;
  }

  // WebSocket clients. acceptWebSocket lets the room sleep between messages.
  async fetch(request) {
    if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
      return new Response("expected a WebSocket", { status: 426 });
    }
    const [client, server] = Object.values(new WebSocketPair());
    this.ctx.acceptWebSocket(server);
    server.send(JSON.stringify({ type: "history", messages: this.history() }));
    return new Response(null, { status: 101, webSocket: client });
  }

  webSocketMessage(ws, data) {
    try {
      const { author, text } = JSON.parse(data);
      this.post(author, text);
    } catch (error) {
      ws.send(JSON.stringify({ type: "error", error: String(error?.message ?? error) }));
    }
  }

  webSocketClose(ws, code) {
    try { ws.close(code, "bye"); } catch {}
  }
}

// /api/rooms/<room>/messages (GET, POST) and /api/rooms/<room>/ws.
// Everything else is a file in public/.
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const match = url.pathname.match(/^\/api\/rooms\/([a-z0-9-]{1,32})\/(messages|ws)$/);
    if (!match) return new Response("not found", { status: 404 });
    const [, name, what] = match;
    const room = env.ROOMS.getByName(name);
    if (what === "ws") return room.fetch(request);
    if (request.method === "GET") return Response.json(await room.history());
    if (request.method === "POST") {
      const { author, text } = await request.json();
      return Response.json(await room.post(author, text), { status: 201 });
    }
    return new Response("method not allowed", { status: 405 });
  },
};
EOF

The page connects to its room and reconnects when celld restarts:

cat > ~/chat/public/index.html <<'EOF'
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat on celld</title>
<style>
  body { font: 16px/1.4 system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
  #log { list-style: none; padding: 0; min-height: 12rem; }
  #log b { margin-right: .4rem; }
  form { display: flex; gap: .5rem; }
  #text { flex: 1; }
  small { color: #666; }
</style>
<h1>#<span id="room"></span></h1>
<p><small id="status">connecting…</small></p>
<ul id="log"></ul>
<form id="form">
  <input id="author" placeholder="your name" size="10">
  <input id="text" placeholder="say something" autocomplete="off" autofocus>
  <button>Send</button>
</form>
<script>
  const room = (new URLSearchParams(location.search).get("room") || "lobby").toLowerCase();
  const $ = (id) => document.getElementById(id);
  $("room").textContent = room;
  $("author").value = localStorage.author || "";
  let ws;
  function show(m) {
    const li = document.createElement("li");
    li.innerHTML = "<b></b><span></span> <small></small>";
    li.children[0].textContent = m.author;
    li.children[1].textContent = m.text;
    li.children[2].textContent = new Date(m.sent_at).toLocaleTimeString();
    $("log").append(li);
  }
  function connect() {
    ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/rooms/${room}/ws`);
    ws.onopen = () => ($("status").textContent = "connected: open this page in another tab and say hi");
    ws.onmessage = (e) => {
      const msg = JSON.parse(e.data);
      if (msg.type === "history") { $("log").replaceChildren(); msg.messages.forEach(show); }
      if (msg.type === "message") show(msg.message);
      if (msg.type === "error") $("status").textContent = msg.error;
    };
    ws.onclose = () => { $("status").textContent = "reconnecting…"; setTimeout(connect, 1000); };
  }
  $("form").onsubmit = (e) => {
    e.preventDefault();
    localStorage.author = $("author").value;
    ws.send(JSON.stringify({ author: $("author").value, text: $("text").value }));
    $("text").value = "";
  };
  connect();
</script>
EOF

Deploy it to the bucket. celld starts serving once a deployment exists:

~/celld/run celld deploy ~/chat

Expect a table with env.ROOMS (Room) Durable Object (SQLite) and a Current Version ID.

Start celld#

celld serves the Worker on port 8000, which exe.dev's HTTPS proxy publishes as https://my-celld.exe.xyz/. Its internal port (peers and operator commands) stays on localhost, outside the proxied range:

sudo tee /etc/systemd/system/celld.service >/dev/null <<'EOF'
[Unit]
Description=celld: Workers and Durable Objects
Wants=network-online.target
After=network-online.target
# Always come back: a node that loses its bucket lease exits on purpose.
StartLimitIntervalSec=0

[Service]
User=exedev
EnvironmentFile=/home/exedev/celld/celld.env
ExecStart=/home/exedev/.local/bin/celld --listen 0.0.0.0:8000 --internal-listen 127.0.0.1:18081 --trust-forwarded-headers
Restart=always
# Longer than a lease (10 s), as celld asks.
RestartSec=15
# A clean stop hands every object back to the bucket first.
TimeoutStopSec=60

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now celld
until curl -fs localhost:8000/.well-known/celld/health; do sleep 1; done; echo
systemctl is-enabled celld

Expect {"ok":true} and enabled. --trust-forwarded-headers is safe here because exe.dev's proxy sets X-Forwarded-Host and X-Forwarded-Proto, so request.url reads https://my-celld.exe.xyz/....

Add two commands for later: ~/celld/start starts celld and waits until it serves, and ~/celld/deploy loads a new version of the app at once, instead of at the next 30-second check:

cat > ~/celld/start <<'EOF'
#!/bin/sh
# Start celld and wait, up to two minutes, until it serves.
sudo systemctl start celld
for _ in $(seq 120); do
  curl -fs localhost:8000/.well-known/celld/health >/dev/null && exit 0
  sleep 1
done
echo "celld isn't serving yet; see journalctl -u celld" >&2
exit 1
EOF
cat > ~/celld/deploy <<'EOF'
#!/bin/sh
# Deploy a Wrangler project to this VM's celld: ~/celld/deploy ~/chat
set -eu
"$HOME/celld/run" celld deploy "${1:-.}"
if systemctl is-active --quiet celld; then
  curl -fsS -X POST http://127.0.0.1:18081/reload && echo || echo "celld loads it within 30 seconds"
fi
EOF
chmod +x ~/celld/start ~/celld/deploy

Try it#

Post a message and read it back:

curl -fsS -X POST localhost:8000/api/rooms/lobby/messages \
  -H 'Content-Type: application/json' -d '{"author":"shelley","text":"Hello from the VM!"}'; echo
curl -fsS localhost:8000/api/rooms/lobby/messages; echo

Now prove the bucket holds it: stop celld, empty its working copy, and start again. A clean stop takes about 15 seconds:

sudo systemctl stop celld
find ~/celld/state -mindepth 1 -delete
~/celld/start
curl -fsS localhost:8000/api/rooms/lobby/messages; echo

The message comes back, read from the bucket.

Set up snapshots#

The bucket is the durable copy. A snapshot protects against what happens to the bucket: a bad deploy, a bug, a deleted file. celld stops while rclone copies, so each snapshot is consistent. Both commands return once celld serves again:

cat > ~/celld/snapshot <<'EOF'
#!/bin/sh
# Copy the bucket into ~/celld/snapshots/<UTC time>; keep the newest 7.
# celld stops during the copy, so the snapshot is consistent.
set -eu
dest="$HOME/celld/snapshots/$(date -u +%Y%m%d-%H%M%S)"
sudo systemctl stop celld
trap '"$HOME/celld/start"' EXIT
"$HOME/celld/run" rclone copy celld: "$dest.partial"
mv "$dest.partial" "$dest"
rm -rf "$HOME"/celld/snapshots/*.partial
ls -1d "$HOME"/celld/snapshots/2*[0-9] | head -n -7 | xargs -r rm -rf --
echo "$dest"
EOF
cat > ~/celld/restore <<'EOF'
#!/bin/sh
# Make the bucket match a snapshot: ~/celld/restore ~/celld/snapshots/<time>
# Writes made since that snapshot are lost.
set -eu
snapshot=$(realpath "${1:?usage: ~/celld/restore ~/celld/snapshots/<time>}")
case "$snapshot" in *.partial) echo "unfinished snapshot: $snapshot" >&2; exit 1 ;; esac
[ -d "$snapshot/deploy" ] || { echo "not a celld snapshot: $snapshot" >&2; exit 1; }
sudo systemctl stop celld
trap '"$HOME/celld/start"' EXIT
"$HOME/celld/run" rclone sync "$snapshot" celld:
find "$HOME/celld/state" -mindepth 1 -delete
EOF
chmod +x ~/celld/snapshot ~/celld/restore

Take one, post a message after it, then restore it:

~/celld/snapshot
curl -fsS -X POST localhost:8000/api/rooms/lobby/messages \
  -H 'Content-Type: application/json' -d '{"author":"shelley","text":"This one goes away"}'; echo
~/celld/restore "$(ls -1d ~/celld/snapshots/2*[0-9] | tail -n 1)"
curl -fsS localhost:8000/api/rooms/lobby/messages; echo

Expect "Hello from the VM!" without "This one goes away". Then take a snapshot every night:

sudo tee /etc/systemd/system/celld-snapshot.service >/dev/null <<'EOF'
[Unit]
Description=Snapshot celld's bucket into ~/celld/snapshots

[Service]
Type=oneshot
User=exedev
ExecStart=/home/exedev/celld/snapshot
EOF
sudo tee /etc/systemd/system/celld-snapshot.timer >/dev/null <<'EOF'
[Unit]
Description=Snapshot celld's bucket every night

[Timer]
OnCalendar=*-*-* 03:30:00 UTC
RandomizedDelaySec=30m
Persistent=true

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now celld-snapshot.timer
systemctl list-timers celld-snapshot.timer

The timer should list its next run. Installation done: open https://my-celld.exe.xyz/ and say hello.

Back up and restore

On the VM, ~/celld/snapshot takes an extra snapshot and prints its directory. Take one before every upgrade. Snapshots hold everything in the bucket, so keep them private.

Undo to a snapshot#

This puts the bucket back the way it was, and loses every write since:

ls ~/celld/snapshots
~/celld/restore ~/celld/snapshots/20261016-033012

Use a directory from the listing in place of the example.

Move to a new VM#

The bucket holds your app and its objects, so a new VM only needs celld.env. Follow Install by hand on the new VM up to Connect the bucket, paste your saved celld.env instead of the template, and skip Write the chat and the empty-bucket check. Then follow Start celld and Set up snapshots, without the restore test. Before you start the new VM's celld, stop the old one and its snapshots, on the old VM: sudo systemctl disable --now celld celld-snapshot.timer.

Lost the bucket itself? Create a new one, point celld.env at it, and restore a snapshot into it with ~/celld/restore.

Update celld

Read the upgrade notes for each version between yours and the new one: some say stop all, which one node does anyway. Then, on the VM, with the version you picked from the releases:

~/celld/snapshot
curl -fsSL https://celld.dev/install.sh | CELLD_VERSION=v0.7.0 sh   # the version you picked
sudo systemctl restart celld
until curl -fs localhost:8000/.well-known/celld/health; do sleep 1; done; echo
celld --version

To roll back, run the installer with the previous version and restart. If the notes say an upgrade changes the bucket's format, restore the snapshot you took first.

Something not working?

On the VM:

journalctl -u celld -n 100 --no-pager
~/celld/run celld diagnose
  • The page hangs, or the health check never answers: celld waits for its first deployment. Run ~/celld/run celld deploy ~/chat.
  • celld won't start, with a storage error: check the five bucket values in ~/celld/celld.env. The endpoint must not end in the bucket name, and the bucket must support conditional writes.
  • celld deploy stops on a config key: celld refuses Wrangler keys it doesn't support, such as routes, instead of ignoring them. See Cloudflare compatibility.
  • Slow writes: one node waits for the bucket on every write. Move the bucket closer to the VM, or add a second node.
  • WebSockets drop after a deploy: expected. A busy object moves to the new code within a minute and closes its regular WebSockets; the page reconnects on its own.

Frequently asked questions#

Is celld ready for production?

It's young: 0.6.2 is a beta from October 2026, with a new release about every week and upgrade notes. It trusts the code it runs, so run your own app, not strangers'. Read its limitations and security pages before you bet a business on it.

What does it cost?

Your VM plan and your bucket. An idle object costs nothing, but an idle node still checks in with its bucket, about 70 times a minute with these settings: some 3 million requests a month, a few dollars on R2 beyond its free tier. Every write adds a request, too.

Which buckets work?

celld needs conditional writes, so use Cloudflare R2, Amazon S3, Tigris, Google Cloud Storage, or Azure Blob Storage; Backblaze B2, Hetzner, and DigitalOcean Spaces don't qualify. This recipe uses the S3 settings; Google and Azure take their own.

How fast are writes?

With one node, each write waits for the bucket: about 90 ms when the bucket is in the VM's region, more when it's far away. Reads come from the object's local SQLite database. Pick a bucket location near your VM, or add a second node.

Can I add a second node?

Yes. Nodes find each other through the bucket, but they talk over a private network: join both VMs to Tailscale with exe.dev's Tailscale integration, start each with --internal-listen and --advertise on its tailnet address, and remove CELLD_DURABILITY and CELLD_REBALANCE_INTERVAL_MS from celld.env. A write then commits as soon as the other node has it on disk. Each VM keeps its own URL, and a snapshot needs every node stopped.

Can I run my existing Cloudflare Worker?

Often, yes. A celld fleet runs one app, so deploying your project replaces the chat. To bring your Durable Objects' data along, see Migrate a Durable Object from Cloudflare.

Updated Oct 16, 2026 · Markdown · manifest.json · Something wrong? Tell us