---
title: "A Durable Object with celld"
description: "Run Cloudflare-style Durable Objects on your own exe.dev VM with celld: a SQLite database per object, saved to a bucket you own."
url: https://exe.dev/deploy/celld
category: "Serverless"
updated: 2026-10-16
---

# A Durable Object with celld

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

- **Software:** celld (https://celld.dev)
- **Source:** https://github.com/denoland/celld
- **License:** Apache-2.0
- **Time:** 15 minutes
- **Example VM name:** `my-celld` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe.dev/deploy/celld/manifest.json

## 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.

Running on the VM:

- **celld 0.6**: Workers and Durable Objects, managed by systemd, port 8000, data in `~/celld/state`
- **Chat**: A Worker with one Durable Object per room, data in `~/chat`
- **Your bucket**: Every object's database, saved before celld answers, data in `R2, S3, or Tigris`
- **Nightly snapshots**: A copy of the bucket on the VM; seven kept, data in `~/celld/snapshots`

How it is reached:

- https://my-celld.exe.xyz/ (private): The chat, or whatever you deploy next

## Deploy with Shelley

Open https://exe.dev/new?prompt=CELLD_BUCKET%3D%3Cfill-this-in%3E%0AS3_ENDPOINT%3D%3Cfill-this-in%3E%0AAWS_REGION%3D%3Cfill-this-in%3E%0AAWS_ACCESS_KEY_ID%3D%3Cfill-this-in%3E%0AAWS_SECRET_ACCESS_KEY%3D%3Cfill-this-in%3E%0A%0ASet%20up%20celld%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Fcelld.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20CELLD_BUCKET%3A%20an%20empty%20bucket%2C%20written%20%60s3%3A%2F%2Fname%60%20%28%5Bget%20one%5D%28%23get-a-bucket%29%29%0A-%20S3_ENDPOINT%3A%20R2%3A%20%60https%3A%2F%2F%3CACCOUNT_ID%3E.r2.cloudflarestorage.com%60%3B%20Tigris%3A%20%60https%3A%2F%2Ft3.storage.dev%60%3B%20Amazon%20S3%3A%20leave%20empty%0A-%20AWS_REGION%3A%20%60auto%60%20for%20R2%20and%20Tigris%3B%20the%20bucket%27s%20region%20for%20Amazon%20S3%0A-%20AWS_ACCESS_KEY_ID%3A%20a%20key%20that%20can%20read%20and%20write%20that%20bucket%2C%20and%20nothing%20else%0A-%20AWS_SECRET_ACCESS_KEY%3A%20the%20key%27s%20secret%0AAsk%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%20before%20you%20need%20them.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-celld.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-celld.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20celld%200.6.2%20serving%20a%20chat%20at%20%60https%3A%2F%2F%3Cvm%3E.exe.xyz%2F%60%2C%20one%20Durable%20Object%20per%20room.%0A-%20Every%20write%20in%20your%20bucket%20before%20celld%20answers%2C%20with%20the%20bucket%27s%20keys%20in%20one%20private%20file.%0A-%20A%20message%20that%20survived%20restarting%20celld%20with%20an%20empty%20local%20cache.%0A-%20celld%20starting%20at%20boot%20and%20coming%20back%20after%20a%20crash.%0A-%20Nightly%20snapshots%20of%20the%20bucket%2C%20and%20one%20restored%20to%20prove%20it%20works.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details.%20Follow%20%22Install%20by%0Ahand%22%20first%2C%20skipping%20VM%20creation%3B%20the%20walkthrough%20is%20for%20the%20owner%0Aafterwards.%20Do%20not%20run%20the%20later%20Restore%20or%20Update%20instructions.%0AWrite%20the%20owner%27s%20five%20values%20into%20~%2Fcelld%2Fcelld.env%20and%20never%20print%20the%0Asecret%20key.%20Never%20overwrite%20an%20existing%20celld.env.%20The%20bucket%20must%20be%0Aempty%3A%20if%20%60~%2Fcelld%2Frun%20rclone%20lsf%20celld%3A%60%20lists%20anything%2C%20stop%20and%20ask%0Athe%20owner%2C%20because%20celld%20treats%20everything%20in%20its%20bucket%20as%20its%20own.%0AKeep%20the%20VM%20private%3B%20do%20not%20run%20share%20set-public.%0ACheck%20the%20health%20endpoint%2C%20a%20posted%20message%2C%20the%20same%20message%20after%0Arestarting%20celld%20with%20an%20empty%20~%2Fcelld%2Fstate%2C%20that%20celld.service%20and%0Acelld-snapshot.timer%20are%20enabled%2C%20and%20the%20snapshot%20restore%20test.%0AFinish%20with%20https%3A%2F%2F%3Cvm%3E.exe.xyz%2F%2C%20using%20this%20VM%27s%20name%2C%20and%20tell%20the%0Aowner%20to%20edit%20~%2Fchat%20and%20run%20~%2Fcelld%2Fdeploy%20~%2Fchat%20to%20ship%20changes.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open.%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe.dev/deploy/celld/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Or follow the steps below by hand.

[celld](https://celld.dev) 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.

## 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](#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:

```bash
~/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:

```bash
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](/docs/cnames).

## 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:

```bash
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.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Install by hand</summary>

### Create a VM

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

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
~/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](/docs/proxy) publishes as `https://my-celld.exe.xyz/`. Its
internal port (peers and operator commands) stays on localhost, outside
the proxied range:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
~/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:

```bash
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](#say-hello).

</details>

<details id="back-up-and-restore" class="dp-details">
<summary>Back up and restore</summary>

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:

```bash
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](#create-the-vm) 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`.

</details>

<details id="update" class="dp-details">
<summary>Update celld</summary>

Read the [upgrade notes](https://celld.dev/docs#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](https://github.com/denoland/celld/releases):

```bash
~/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.

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

On the VM:

```bash
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](https://celld.dev/docs/cloudflare-compat).
- **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.

</details>

## 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](https://celld.dev/docs#upgrade-notes). It trusts the code it runs, so run your own app, not strangers'. Read its [limitations](https://celld.dev/docs/limitations) and [security](https://celld.dev/docs/security) pages before you bet a business on it.

### What does it cost?

Your [VM plan](https://exe.dev/pricing) 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](https://celld.dev/docs#configure-object-storage).

### 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](/docs/integrations-tailscale-wif), 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](/deploy/migrate-durable-objects).


## Related

- [Migrate a Durable Object from Cloudflare](https://exe.dev/deploy/migrate-durable-objects.md): Same Worker, same objects, new home. Every key and row checked.
- [Run Claude Code on a remote server](https://exe.dev/deploy/claude-code.md): A computer for Claude. A breather for your laptop.
- [VS Code in your browser, with code-server](https://exe.dev/deploy/code-server.md): Your editor in a tab. Your projects right where you left them.
- [Self-host n8n](https://exe.dev/deploy/n8n.md): Connect your apps. Let the boring bits run themselves.
- [Host OpenClaw](https://exe.dev/deploy/openclaw.md): An AI sidekick with its own computer. Your laptop can take a nap.
- [Self-host Uptime Kuma](https://exe.dev/deploy/uptime-kuma.md): A little lookout for your websites. Green bars encouraged.
