diff --git a/.github/labeler.yml b/.github/labeler.yml
index ad75ed371b4d..785fc3d553fb 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -323,6 +323,12 @@
- "docs/install/docker.md"
- "docs/tools/multi-agent-sandbox-tools.md"
+"deploy: cloudflare":
+ - changed-files:
+ - any-glob-to-any-file:
+ - "scripts/cloudflare/**"
+ - "docs/install/cloudflare.md"
+
"agents":
- changed-files:
- any-glob-to-any-file:
diff --git a/config/knip.config.ts b/config/knip.config.ts
index 119ad944b234..2ff3d4a53e15 100644
--- a/config/knip.config.ts
+++ b/config/knip.config.ts
@@ -25,6 +25,8 @@ const repositoryScriptEntries = [
"scripts/check-control-ui-precompressed-assets.mts!",
"scripts/check-live-cache.ts!",
"scripts/check-package-dist-imports.mjs!",
+ // Cloudflare deployment template: wrangler bundles the Worker from this entry.
+ "scripts/cloudflare/src/index.ts!",
"scripts/dev/ios-node-e2e.ts!",
"scripts/diffs-shiki-curated.ts!",
// Reusable Docker workflows invoke this from the downloaded .release-harness tree.
@@ -434,6 +436,9 @@ const config = {
".": {
ignoreDependencies: [
"@openclaw/*",
+ // Cloudflare template dependency: declared in scripts/cloudflare/package.json
+ // (isolated deploy tooling), not in the root manifest.
+ "@cloudflare/containers",
// Docker packaging stages @openclaw/ai without nested dependencies after
// verifying the root owns its exact runtime dependency versions.
"@mistralai/mistralai",
diff --git a/config/knip.scripts-exports.config.ts b/config/knip.scripts-exports.config.ts
index 0e0cb8e5ea86..b0cc86fb0d45 100644
--- a/config/knip.scripts-exports.config.ts
+++ b/config/knip.scripts-exports.config.ts
@@ -58,6 +58,10 @@ const config = {
],
// Oxlint consumes this required default export through a JSON config path.
"scripts/oxlint-boundary-guards.mjs": ["exports"],
+ // Wrangler consumes the Worker default export and instantiates the Durable
+ // Object class by name from wrangler.jsonc; Knip cannot resolve either.
+ "scripts/cloudflare/src/index.ts": ["exports"],
+ "scripts/cloudflare/src/container.ts": ["exports"],
"src/**": ["exports", "nsExports", "types", "nsTypes", "enumMembers", "namespaceMembers"],
"test/**": ["exports", "nsExports", "types", "nsTypes", "enumMembers", "namespaceMembers"],
},
diff --git a/docs/docs.json b/docs/docs.json
index 3eb8a23d2175..5210dbaaeebe 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -1115,6 +1115,7 @@
"group": "Hosting",
"pages": [
"install/azure",
+ "install/cloudflare",
"install/daytona",
"install/digitalocean",
"install/docker-vm-runtime",
diff --git a/docs/install/cloudflare.md b/docs/install/cloudflare.md
new file mode 100644
index 000000000000..6ddab5a93d9f
--- /dev/null
+++ b/docs/install/cloudflare.md
@@ -0,0 +1,199 @@
+---
+summary: "Experimental Cloudflare Worker and Container deployment with Litestream backups to R2"
+title: "Cloudflare Containers"
+read_when:
+ - You want to run OpenClaw on Cloudflare Containers
+ - You are evaluating R2-backed SQLite recovery on ephemeral containers
+ - You need to choose between webhook scale-to-zero and always-on channels
+---
+
+Run one OpenClaw installation behind a Cloudflare Worker and a named Durable Object, with the official OpenClaw image and Litestream replication to R2.
+
+
+ This deployment target is experimental. Litestream protects SQLite databases, not the complete OpenClaw state directory. Read [Limits and recovery](#limits-and-recovery) before using production credentials.
+
+
+## What you need
+
+- A Cloudflare account with Workers, Containers, and R2 available
+- Docker Buildx with `linux/amd64` support
+- A public Docker Hub repository for the derived image
+- Node.js and npm
+- Provider and channel credentials for your OpenClaw setup
+
+The template lives in [`scripts/cloudflare`](https://github.com/openclaw/openclaw/tree/main/scripts/cloudflare). It deploys a `standard-2` Container with `max_instances: 1`.
+
+## How it works
+
+The Worker forwards every HTTP and WebSocket request to one stable Durable Object name. That Durable Object owns one Container instance and is the single-writer fence around the Litestream replica. The Container exposes OpenClaw on port `8080`; `/startupz` is its traffic-readiness check.
+
+Litestream watches both SQLite roots:
+
+- `/home/node/.openclaw/state/*.sqlite`
+- `/home/node/.openclaw/agents/**/*.sqlite`
+
+At boot, the entrypoint uses R2's S3 `ListObjectsV2` API as the restore manifest, rejects paths outside those roots, restores each discovered database, and only then starts the Gateway.
+
+## Deploy
+
+
+
+ Clone OpenClaw and enter the template directory:
+
+ ```bash
+ git clone https://github.com/openclaw/openclaw.git
+ cd openclaw/scripts/cloudflare
+ npm install
+ npx wrangler login
+ npx wrangler whoami
+ ```
+
+ Confirm that Wrangler selected the intended Cloudflare account before creating resources.
+
+
+
+
+ Create the bucket:
+
+ ```bash
+ npx wrangler r2 bucket create openclaw-backups
+ ```
+
+ In the Cloudflare dashboard, create an R2 API token with object read/write access limited to that bucket. Keep the access key ID and secret access key out of the checkout.
+
+ In `wrangler.jsonc`, replace `` in the endpoint. If you use another bucket name, update both `LITESTREAM_BUCKET` and `r2_buckets[].bucket_name`.
+
+ The R2 binding is for Worker-side access and documentation completeness. Litestream cannot use a Worker binding from inside the Container; it uses R2's S3 endpoint and credentials passed through Worker secrets.
+
+
+
+
+ Replace `` in `Dockerfile` with an immutable digest from the official [`openclaw/openclaw`](https://hub.docker.com/r/openclaw/openclaw) Docker Hub repository.
+
+ Build the derived image for Cloudflare's required architecture and push it to a public Docker Hub repository:
+
+ ```bash
+ docker buildx build \
+ --platform linux/amd64 \
+ --tag docker.io//openclaw-cloudflare: \
+ --push \
+ .
+ docker buildx imagetools inspect \
+ docker.io//openclaw-cloudflare:
+ ```
+
+ Replace the `containers[].image` placeholder in `wrangler.jsonc` with the resulting immutable `docker.io/...@sha256:...` reference. Cloudflare Containers can pull public Docker Hub images directly; GHCR is not a supported source for this template.
+
+
+
+
+ Compile the Worker and deploy it:
+
+ ```bash
+ npm run check
+ npm run deploy
+ ```
+
+ The first deployment creates the Worker, the SQLite-backed Durable Object class, the Container application, and the R2 binding.
+
+
+
+
+ Add the R2 and Gateway credentials through Wrangler's secret prompt:
+
+ ```bash
+ npx wrangler secret put LITESTREAM_ACCESS_KEY_ID
+ npx wrangler secret put LITESTREAM_SECRET_ACCESS_KEY
+ npx wrangler secret put OPENCLAW_GATEWAY_TOKEN
+ ```
+
+ Add provider and channel variables as needed. For example:
+
+ ```bash
+ npx wrangler secret put OPENAI_API_KEY
+ npx wrangler secret put TELEGRAM_BOT_TOKEN
+ ```
+
+ `src/container.ts` passes an explicit allowlist of environment variables to the Container. Add another name there before using a different environment-backed credential.
+
+
+
+
+ First boot needs one interactive session inside the Container. SSH access ships disabled; enable it temporarily by adding this to the container entry in `wrangler.jsonc`, then redeploy:
+
+ ```jsonc
+ "ssh": { "enabled": true }
+ ```
+
+ Open the deployed Worker URL once to start the instance. Then locate the application and instance IDs and connect:
+
+ ```bash
+ npx wrangler containers list
+ npx wrangler containers instances --json
+ npx wrangler containers ssh
+ ```
+
+ SSH is wrangler-mediated and limited to accounts with container write access. After bootstrap you can remove the `ssh` block and redeploy; the restored state survives the replacement via Litestream.
+
+ Inside the Container, run a SecretRef-based setup. This example uses OpenAI and Telegram:
+
+ ```bash
+ cd /app
+ node openclaw.mjs onboard --non-interactive --accept-risk --skip-health \
+ --mode local \
+ --auth-choice openai-api-key \
+ --secret-input-mode ref \
+ --gateway-auth token \
+ --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \
+ --skip-channels \
+ --no-install-daemon
+ node openclaw.mjs channels add --channel telegram --use-env
+ node openclaw.mjs doctor --json
+ ```
+
+ Keep your exact bootstrap recipe in a private, reproducible runbook. A fresh Container disk does not retain the generated config.
+
+
+
+
+## Choose the lifecycle mode
+
+`OPENCLAW_WEBHOOK_ONLY` defaults to `false`, which keeps the Container running through idle periods. Keep this default for channels that maintain sockets or long-lived processes, including:
+
+- Discord
+- Slack Socket Mode
+- WhatsApp
+
+Set `OPENCLAW_WEBHOOK_ONLY` to `true` only when every enabled channel receives traffic through HTTP webhooks. In that mode, the Container stops after ten idle minutes and cold-starts on the next request.
+
+
+ Scale-to-zero starts with a fresh disk. Enable it only when an external process can reapply your declarative bootstrap. Litestream restores SQLite but cannot recreate `openclaw.json`, credential files, installed plugins, or workspaces.
+
+
+## Limits and recovery
+
+- **Single writer:** every request resolves the same Durable Object name, and Cloudflare runs one live Durable Object instance for that name. Do not increase `max_instances` or introduce alternate routing around this fence. A brief old/new Container overlap during a platform replacement or rollout is an accepted experimental tradeoff.
+- **Recovery point:** the one-second Litestream sync interval normally produces a seconds-scale RPO. It is not synchronous replication, and abrupt termination can lose writes that have not reached R2.
+- **Ephemeral disk:** every sleep, replacement, or host restart starts from the image plus the restored SQLite databases. Use [full OpenClaw archives](/install/backups#full-archives) for config, credential files, plugin files, and workspaces.
+- **Rollback:** older database bytes are time travel. Ratcheting channel credentials, especially WhatsApp, can desynchronize; approvals and delivery/dedupe state also roll back. Relink affected channels and review pending approvals before resuming. See [Restore](/install/backups#restore).
+- **WebSockets:** Worker and Container proxying supports WebSockets. Cloudflare limits each received WebSocket message to 32 MiB.
+- **Egress:** outbound requests use shared Cloudflare IP space. This target does not provide a fixed egress address.
+- **Provider boundary:** this is a deployment template, not an OpenClaw `cloudWorkers` provider. Its operator SSH access does not implement that provider's SSH execution contract.
+
+## Update
+
+Build a new derived image from a new immutable official OpenClaw digest, push it, update the derived digest in `wrangler.jsonc`, and deploy:
+
+```bash
+npm run check
+npm run deploy
+```
+
+Test updates and rollbacks against a separate R2 bucket first. Preserve current state before activating older bytes.
+
+## Related
+
+- [Backups](/install/backups)
+- [Docker](/install/docker)
+- [Gateway security](/gateway/security)
+- [Secrets management](/gateway/secrets)
diff --git a/docs/install/index.md b/docs/install/index.md
index b76bc758ea2c..73f52c6dd1cb 100644
--- a/docs/install/index.md
+++ b/docs/install/index.md
@@ -186,10 +186,14 @@ If you want managed startup after install:
Deploy OpenClaw on a cloud server or VPS. See [Linux server](/vps) for the full
provider picker (DigitalOcean, Hetzner, Hostinger, Fly.io, GCP, Azure, Railway,
-Northflank, Oracle Cloud, Raspberry Pi, and more), or deploy declaratively on
-[Render](/install/render).
+Northflank, Oracle Cloud, Raspberry Pi, and more), deploy declaratively on
+[Render](/install/render), or try the experimental [Cloudflare Containers](/install/cloudflare)
+template.
+
+ Experimental Worker + Container deployment.
+
Pick a provider.
diff --git a/scripts/cloudflare/Dockerfile b/scripts/cloudflare/Dockerfile
new file mode 100644
index 000000000000..09522c4e7d31
--- /dev/null
+++ b/scripts/cloudflare/Dockerfile
@@ -0,0 +1,24 @@
+# Replace the placeholder with an immutable linux/amd64 digest from the official
+# Docker Hub repository. Cloudflare Containers cannot pull OpenClaw from GHCR.
+ARG OPENCLAW_IMAGE=openclaw/openclaw@sha256:
+FROM ${OPENCLAW_IMAGE}
+
+USER root
+
+ARG LITESTREAM_VERSION=0.5.16
+ARG LITESTREAM_SHA256=9e29112380a942e4a62ee07773684396cb8b308dc4d67e130bef41f75e937f0a
+
+ADD --checksum=sha256:${LITESTREAM_SHA256} \
+ https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-${LITESTREAM_VERSION}-linux-x86_64.tar.gz \
+ /tmp/litestream.tar.gz
+RUN tar -xzf /tmp/litestream.tar.gz -C /usr/local/bin litestream \
+ && chmod 0755 /usr/local/bin/litestream \
+ && rm /tmp/litestream.tar.gz
+
+COPY litestream.yml /etc/litestream.yml
+COPY entrypoint.sh /usr/local/bin/cloudflare-entrypoint.sh
+RUN chmod 0755 /usr/local/bin/cloudflare-entrypoint.sh \
+ && chown root:root /etc/litestream.yml /usr/local/bin/cloudflare-entrypoint.sh
+
+USER node
+ENTRYPOINT ["tini", "-s", "--", "/usr/local/bin/cloudflare-entrypoint.sh"]
diff --git a/scripts/cloudflare/README.md b/scripts/cloudflare/README.md
new file mode 100644
index 000000000000..a1dd643fe6d7
--- /dev/null
+++ b/scripts/cloudflare/README.md
@@ -0,0 +1,164 @@
+# OpenClaw on Cloudflare Containers (experimental)
+
+This template runs one OpenClaw installation behind a Cloudflare Worker and one named Durable Object. The Durable Object starts a `standard-2` Container from a public, digest-pinned Docker Hub image. Litestream continuously replicates the global and per-agent SQLite databases to R2 through its S3-compatible API.
+
+This is an experimental deployment target. Read [Operational constraints](#operational-constraints) before using it with real credentials or relying on it for recovery.
+
+## Architecture
+
+```text
+HTTP/WebSocket request
+ |
+ v
+Cloudflare Worker
+ |
+ v
+OpenClawContainer Durable Object (one stable name)
+ |
+ v
+OpenClaw + Litestream container :8080
+ |
+ +--> R2 S3 API (SQLite replicas)
+```
+
+Every HTTP and WebSocket request is forwarded to port `8080`. The Container helper checks `GET /startupz` before admitting traffic. `max_instances: 1` and the single Durable Object name are the installation's outer single-writer fence.
+
+## Prerequisites
+
+- A Cloudflare account with Workers, Containers, and R2 available
+- Docker Buildx with `linux/amd64` support
+- A public Docker Hub repository for the derived image
+- Node.js and npm
+- Model-provider and channel credentials for the OpenClaw setup you choose
+
+## 1. Create the R2 bucket and S3 credentials
+
+From this directory:
+
+```bash
+npm install
+npx wrangler login
+npx wrangler whoami
+npx wrangler r2 bucket create openclaw-backups
+```
+
+In the Cloudflare dashboard, create an R2 API token with object read/write access limited to this bucket. Record its access key ID and secret access key. Do not put either value in this checkout.
+
+Edit `wrangler.jsonc`:
+
+- replace `` in `LITESTREAM_ENDPOINT`
+- change both `LITESTREAM_BUCKET` and `r2_buckets[].bucket_name` if you chose another bucket name
+
+The R2 binding is present for Worker-side completeness. Litestream runs inside the Container and cannot consume a Worker binding directly, so it uses R2's S3 endpoint and Worker secrets passed through `envVars`.
+
+## 2. Build and publish the image
+
+Choose an immutable, architecture-compatible digest from the official [`openclaw/openclaw`](https://hub.docker.com/r/openclaw/openclaw) Docker Hub repository. Replace `` in `Dockerfile`, then build and push the derived image:
+
+```bash
+docker buildx build \
+ --platform linux/amd64 \
+ --tag docker.io//openclaw-cloudflare: \
+ --push \
+ .
+```
+
+Make the derived repository public. Resolve its pushed digest, then replace the `containers[].image` placeholder in `wrangler.jsonc`:
+
+```bash
+docker buildx imagetools inspect docker.io//openclaw-cloudflare:
+```
+
+Use the resulting immutable `docker.io//openclaw-cloudflare@sha256:` reference. Cloudflare Containers can pull public Docker Hub images, but not GHCR images directly.
+
+## 3. Deploy and set secrets
+
+The first deploy creates the Worker, Durable Object migration, Container application, and R2 binding:
+
+```bash
+npm run check
+npm run deploy
+```
+
+Immediately add the R2 and Gateway secrets. `wrangler secret put` prompts without writing the value to shell history:
+
+```bash
+npx wrangler secret put LITESTREAM_ACCESS_KEY_ID
+npx wrangler secret put LITESTREAM_SECRET_ACCESS_KEY
+npx wrangler secret put OPENCLAW_GATEWAY_TOKEN
+```
+
+Add the provider and channel variables needed by your installation, for example:
+
+```bash
+npx wrangler secret put OPENAI_API_KEY
+npx wrangler secret put TELEGRAM_BOT_TOKEN
+```
+
+`src/container.ts` passes the listed optional secret names to the Container. Add another explicit name there before using a different environment-backed provider or channel credential.
+
+## 4. Bootstrap OpenClaw
+
+Open the deployed Worker URL once to start the named instance. Then find the Container application and instance IDs:
+
+```bash
+npx wrangler containers list
+npx wrangler containers instances --json
+npx wrangler containers ssh
+```
+
+Inside the Container, run the non-interactive SecretRef bootstrap. This example uses OpenAI and Telegram; select the provider and webhook-capable channel that match your secrets:
+
+```bash
+cd /app
+node openclaw.mjs onboard --non-interactive --accept-risk --skip-health \
+ --mode local \
+ --auth-choice openai-api-key \
+ --secret-input-mode ref \
+ --gateway-auth token \
+ --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \
+ --skip-channels \
+ --no-install-daemon
+node openclaw.mjs channels add --channel telegram --use-env
+node openclaw.mjs doctor --json
+```
+
+Keep the exact bootstrap recipe in a private, reproducible runbook. Litestream does not replicate `openclaw.json`, credential files, installed plugin files, or workspaces.
+
+## Scale-to-zero policy
+
+The template defaults `OPENCLAW_WEBHOOK_ONLY` to `false`. This keeps the Container alive across idle periods for Discord, Slack Socket Mode, WhatsApp, and every other channel that maintains a socket or polling process.
+
+Set `OPENCLAW_WEBHOOK_ONLY` to `true` only when every enabled channel receives traffic through HTTP webhooks. The Container then stops after ten idle minutes and cold-starts on the next request. Because its disk is fresh after sleep, enable this only when an external process can reapply the declarative bootstrap above; Litestream alone restores SQLite, not the config files needed to activate channels.
+
+## Operational constraints
+
+- **Experimental:** Cloudflare Container lifecycle and rollout behavior can change. Test crash, sleep, rollout, and restore paths with non-production credentials first.
+- **Single-writer fence:** Cloudflare guarantees one live Durable Object instance for a given name, and all Worker requests use the same name. This is the fence around one Litestream replica. A brief old/new Container overlap during replacement or rollout remains an accepted experimental tradeoff; do not raise `max_instances` or route around the named object.
+- **Ephemeral disk:** Every Container restart or sleep starts with a fresh filesystem. The entrypoint lists R2 objects, derives the concrete SQLite restore manifest, restores each database, then starts OpenClaw under Litestream.
+- **Partial durability:** Litestream covers `/home/node/.openclaw/state/*.sqlite` and recursive per-agent SQLite databases only. Use a separate, private [`openclaw backup create`](https://docs.openclaw.ai/install/backups#full-archives) workflow for config, credential files, plugins, and workspaces.
+- **RPO:** `sync-interval: 1s` normally yields a seconds-scale recovery point, not zero data loss. Abrupt termination can lose writes that were not uploaded yet.
+- **Rollback is time travel:** Restoring older state can desynchronize ratcheting channel credentials (especially WhatsApp), roll back approvals, and roll back delivery/dedupe state. Relink affected channels and review pending approvals before resuming.
+- **WebSocket limit:** Cloudflare accepts received WebSocket messages up to 32 MiB. The Worker/Container proxy supports WebSockets; larger individual messages are closed by the platform.
+- **Egress identity:** outbound traffic comes from shared Cloudflare IP space. Providers that require a fixed source IP need another deployment target or an approved egress design.
+- **Not a `cloudWorkers` provider:** this is a hosting template. Operator SSH access is enabled for bootstrap, but the template does not implement OpenClaw's SSH-based cloud-worker provider contract.
+
+## Updating
+
+Build a new derived image from a new immutable official OpenClaw digest, push it, replace the derived digest in `wrangler.jsonc`, and run:
+
+```bash
+npm run check
+npm run deploy
+```
+
+Treat rollbacks like restores: stop traffic where possible, preserve the current state first, and review credentials, approvals, and delivery state before activating older database bytes.
+
+## Files
+
+- `wrangler.jsonc`: Worker, Durable Object, Container application, and R2 binding
+- `src/index.ts`: routes all HTTP and WebSocket traffic to one named instance
+- `src/container.ts`: Container port, readiness, environment, and sleep policy
+- `Dockerfile`: official OpenClaw image plus pinned Litestream for `linux/amd64`
+- `entrypoint.sh`: R2 LIST restore discovery, containment checks, and restore-then-exec flow
+- `litestream.yml`: watched global and per-agent SQLite directory replicas
diff --git a/scripts/cloudflare/entrypoint.sh b/scripts/cloudflare/entrypoint.sh
new file mode 100644
index 000000000000..fc663fa113da
--- /dev/null
+++ b/scripts/cloudflare/entrypoint.sh
@@ -0,0 +1,197 @@
+#!/bin/sh
+set -eu
+
+STATE_ROOT=/home/node/.openclaw
+CONFIG=/etc/litestream.yml
+
+mkdir -p "$STATE_ROOT/state" "$STATE_ROOT/agents"
+
+log() {
+ printf '[cloudflare-entrypoint] %s\n' "$*"
+}
+
+replica_url_for_db() {
+ db_path=$1
+ resolved_path=$(realpath -m "$db_path")
+ case "$resolved_path" in
+ "$STATE_ROOT"/state/*.sqlite)
+ relative_path=${resolved_path#"$STATE_ROOT/state/"}
+ replica_path="replicas/state/$relative_path"
+ ;;
+ # case globs match "/" (fnmatch without FNM_PATHNAME), so this accepts the
+ # nested canonical layout agents//agent/openclaw-agent.sqlite.
+ "$STATE_ROOT"/agents/*.sqlite)
+ relative_path=${resolved_path#"$STATE_ROOT/agents/"}
+ replica_path="replicas/agents/$relative_path"
+ ;;
+ *)
+ log "refusing restore path outside configured directory roots: $db_path"
+ return 1
+ ;;
+ esac
+
+ printf 's3://%s/%s?endpoint=%s®ion=%s&forcePathStyle=true\n' \
+ "$LITESTREAM_BUCKET" "$replica_path" "$LITESTREAM_ENDPOINT" "$LITESTREAM_REGION"
+}
+
+list_replica_databases() {
+ node --input-type=module <<'NODE'
+import { createHash, createHmac } from "node:crypto";
+
+const {
+ LITESTREAM_ACCESS_KEY_ID: accessKeyId,
+ LITESTREAM_BUCKET: bucket,
+ LITESTREAM_ENDPOINT: endpoint,
+ LITESTREAM_REGION: region,
+ LITESTREAM_SECRET_ACCESS_KEY: secretAccessKey,
+} = process.env;
+
+for (const [name, value] of Object.entries({
+ LITESTREAM_ACCESS_KEY_ID: accessKeyId,
+ LITESTREAM_BUCKET: bucket,
+ LITESTREAM_ENDPOINT: endpoint,
+ LITESTREAM_REGION: region,
+ LITESTREAM_SECRET_ACCESS_KEY: secretAccessKey,
+})) {
+ if (!value) {
+ throw new Error(`${name} is required`);
+ }
+}
+
+const sha256 = (value) => createHash("sha256").update(value).digest("hex");
+const hmac = (key, value) => createHmac("sha256", key).update(value).digest();
+const encode = (value) =>
+ encodeURIComponent(value).replace(/[!'()*]/g, (char) =>
+ `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
+ );
+
+function decodeXml(value) {
+ return value
+ .replace(/([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)))
+ .replace(/([0-9]+);/g, (_, code) => String.fromCodePoint(Number.parseInt(code, 10)))
+ .replaceAll(""", '"')
+ .replaceAll("'", "'")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll("&", "&");
+}
+
+async function listPage(continuationToken) {
+ const query = [
+ ["encoding-type", "url"],
+ ["list-type", "2"],
+ ["prefix", "replicas/"],
+ ];
+ if (continuationToken) {
+ query.push(["continuation-token", continuationToken]);
+ }
+ query.sort(([left], [right]) => left.localeCompare(right));
+ const canonicalQuery = query.map(([key, value]) => `${encode(key)}=${encode(value)}`).join("&");
+
+ const url = new URL(endpoint);
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/${encode(bucket)}`;
+ url.search = canonicalQuery;
+
+ const now = new Date();
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
+ const date = amzDate.slice(0, 8);
+ const payloadHash = sha256("");
+ const canonicalHeaders =
+ `host:${url.host}\n` +
+ `x-amz-content-sha256:${payloadHash}\n` +
+ `x-amz-date:${amzDate}\n`;
+ const signedHeaders = "host;x-amz-content-sha256;x-amz-date";
+ const canonicalRequest = [
+ "GET",
+ url.pathname,
+ canonicalQuery,
+ canonicalHeaders,
+ signedHeaders,
+ payloadHash,
+ ].join("\n");
+ const scope = `${date}/${region}/s3/aws4_request`;
+ const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(canonicalRequest)].join("\n");
+ const dateKey = hmac(`AWS4${secretAccessKey}`, date);
+ const regionKey = hmac(dateKey, region);
+ const serviceKey = hmac(regionKey, "s3");
+ const signingKey = hmac(serviceKey, "aws4_request");
+ const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
+
+ const response = await fetch(url, {
+ headers: {
+ Authorization:
+ `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope},` +
+ `SignedHeaders=${signedHeaders},Signature=${signature}`,
+ "x-amz-content-sha256": payloadHash,
+ "x-amz-date": amzDate,
+ },
+ });
+ if (!response.ok) {
+ throw new Error(`R2 ListObjectsV2 failed with HTTP ${response.status}`);
+ }
+
+ const xml = await response.text();
+ const keys = [...xml.matchAll(/([\s\S]*?)<\/Key>/g)].map((match) =>
+ decodeURIComponent(decodeXml(match[1])),
+ );
+ const tokenMatch = xml.match(/([\s\S]*?)<\/NextContinuationToken>/);
+ return {
+ keys,
+ nextToken: tokenMatch ? decodeXml(tokenMatch[1]) : undefined,
+ };
+}
+
+function localDatabasePath(key) {
+ const match = /^replicas\/(state|agents)\/(.+\.sqlite)\/\d{4}\/[^/]+\.ltx$/.exec(key);
+ if (!match) {
+ return undefined;
+ }
+
+ const [, root, relativePath] = match;
+ const segments = relativePath.split("/");
+ if (segments.some((segment) => !segment || segment === "." || segment === ".." || /\s/.test(segment))) {
+ throw new Error(`unsafe replica database path in R2 listing: ${key}`);
+ }
+ return `/home/node/.openclaw/${root}/${segments.join("/")}`;
+}
+
+const databasePaths = new Set();
+let continuationToken;
+do {
+ const page = await listPage(continuationToken);
+ for (const key of page.keys) {
+ const databasePath = localDatabasePath(key);
+ if (databasePath) {
+ databasePaths.add(databasePath);
+ }
+ }
+ continuationToken = page.nextToken;
+} while (continuationToken);
+
+for (const databasePath of [...databasePaths].sort()) {
+ console.log(databasePath);
+}
+NODE
+}
+
+# Directory replication appends each database's relative path to the replica
+# prefix. Restore therefore uses an R2 ListObjectsV2 result as its manifest.
+if ! find "$STATE_ROOT/state" "$STATE_ROOT/agents" -type f -name '*.sqlite' -print -quit | grep -q .; then
+ export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-$LITESTREAM_ACCESS_KEY_ID}"
+ export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-$LITESTREAM_SECRET_ACCESS_KEY}"
+ export AWS_REGION="${AWS_REGION:-$LITESTREAM_REGION}"
+
+ restore_databases=$(list_replica_databases)
+ for db_path in $restore_databases; do
+ replica_url=$(replica_url_for_db "$db_path")
+ mkdir -p "$(dirname "$db_path")"
+ log "restoring database: $db_path"
+ litestream restore -if-replica-exists -integrity-check quick -o "$db_path" "$replica_url"
+ done
+else
+ log "sqlite state already present; restore skipped"
+fi
+
+log "starting Litestream replication with OpenClaw gateway child"
+exec litestream replicate -config "$CONFIG" \
+ -exec "node openclaw.mjs gateway --allow-unconfigured --bind lan --port 8080 --auth token"
diff --git a/scripts/cloudflare/litestream.yml b/scripts/cloudflare/litestream.yml
new file mode 100644
index 000000000000..55bdc430a12e
--- /dev/null
+++ b/scripts/cloudflare/litestream.yml
@@ -0,0 +1,36 @@
+# Both roots need watch:true because OpenClaw creates the shared and per-agent
+# databases after Litestream starts on a new ephemeral container.
+sync-interval: 1s
+logging:
+ level: INFO
+ type: text
+ stderr: false
+
+dbs:
+ - dir: /home/node/.openclaw/state
+ pattern: "*.sqlite"
+ recursive: false
+ watch: true
+ replica:
+ type: s3
+ bucket: ${LITESTREAM_BUCKET}
+ path: replicas/state
+ endpoint: ${LITESTREAM_ENDPOINT}
+ region: ${LITESTREAM_REGION}
+ access-key-id: ${LITESTREAM_ACCESS_KEY_ID}
+ secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY}
+ force-path-style: true
+
+ - dir: /home/node/.openclaw/agents
+ pattern: "*.sqlite"
+ recursive: true
+ watch: true
+ replica:
+ type: s3
+ bucket: ${LITESTREAM_BUCKET}
+ path: replicas/agents
+ endpoint: ${LITESTREAM_ENDPOINT}
+ region: ${LITESTREAM_REGION}
+ access-key-id: ${LITESTREAM_ACCESS_KEY_ID}
+ secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY}
+ force-path-style: true
diff --git a/scripts/cloudflare/package.json b/scripts/cloudflare/package.json
new file mode 100644
index 000000000000..26562cadb7c3
--- /dev/null
+++ b/scripts/cloudflare/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "openclaw-cloudflare-template",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "check": "tsc --noEmit -p tsconfig.json",
+ "deploy": "wrangler deploy"
+ },
+ "dependencies": {
+ "@cloudflare/containers": "0.3.7"
+ },
+ "devDependencies": {
+ "typescript": "6.0.3",
+ "wrangler": "4.122.0"
+ }
+}
diff --git a/scripts/cloudflare/src/cloudflare-containers.d.ts b/scripts/cloudflare/src/cloudflare-containers.d.ts
new file mode 100644
index 000000000000..4e4de4bb2ddc
--- /dev/null
+++ b/scripts/cloudflare/src/cloudflare-containers.d.ts
@@ -0,0 +1,13 @@
+// Keep this deployment template type-checkable without adding Cloudflare packages
+// to the OpenClaw workspace. The isolated package.json supplies the runtime module.
+declare module "@cloudflare/containers" {
+ export class Container {
+ constructor(ctx: unknown, env: Env);
+ defaultPort?: number;
+ envVars: Record;
+ pingEndpoint: string;
+ sleepAfter: string | number;
+ fetch(request: Request): Promise;
+ onActivityExpired(): Promise;
+ }
+}
diff --git a/scripts/cloudflare/src/container.ts b/scripts/cloudflare/src/container.ts
new file mode 100644
index 000000000000..834ff722ea4e
--- /dev/null
+++ b/scripts/cloudflare/src/container.ts
@@ -0,0 +1,74 @@
+import { Container } from "@cloudflare/containers";
+
+interface OpenClawContainerEnv {
+ ANTHROPIC_API_KEY?: string;
+ DISCORD_BOT_TOKEN?: string;
+ LITESTREAM_ACCESS_KEY_ID: string;
+ LITESTREAM_BUCKET: string;
+ LITESTREAM_ENDPOINT: string;
+ LITESTREAM_REGION: string;
+ LITESTREAM_SECRET_ACCESS_KEY: string;
+ OPENAI_API_KEY?: string;
+ OPENCLAW_GATEWAY_TOKEN: string;
+ OPENCLAW_WEBHOOK_ONLY: string;
+ SLACK_APP_TOKEN?: string;
+ SLACK_BOT_TOKEN?: string;
+ TELEGRAM_BOT_TOKEN?: string;
+}
+
+const OPTIONAL_SECRET_NAMES = [
+ "ANTHROPIC_API_KEY",
+ "DISCORD_BOT_TOKEN",
+ "OPENAI_API_KEY",
+ "SLACK_APP_TOKEN",
+ "SLACK_BOT_TOKEN",
+ "TELEGRAM_BOT_TOKEN",
+] as const;
+
+function buildContainerEnv(env: OpenClawContainerEnv): Record {
+ const containerEnv: Record = {
+ LITESTREAM_ACCESS_KEY_ID: env.LITESTREAM_ACCESS_KEY_ID,
+ LITESTREAM_BUCKET: env.LITESTREAM_BUCKET,
+ LITESTREAM_ENDPOINT: env.LITESTREAM_ENDPOINT,
+ LITESTREAM_REGION: env.LITESTREAM_REGION,
+ LITESTREAM_SECRET_ACCESS_KEY: env.LITESTREAM_SECRET_ACCESS_KEY,
+ OPENCLAW_GATEWAY_TOKEN: env.OPENCLAW_GATEWAY_TOKEN,
+ };
+
+ for (const [name, value] of Object.entries(containerEnv)) {
+ if (!value) {
+ throw new Error(`missing required Worker variable or secret: ${name}`);
+ }
+ }
+
+ for (const name of OPTIONAL_SECRET_NAMES) {
+ const value = env[name];
+ if (value) {
+ containerEnv[name] = value;
+ }
+ }
+
+ return containerEnv;
+}
+
+export class OpenClawContainer extends Container {
+ override defaultPort = 8080;
+ override pingEndpoint = "localhost/startupz";
+ override sleepAfter = "10m";
+
+ private readonly webhookOnly: boolean;
+
+ constructor(ctx: unknown, env: OpenClawContainerEnv) {
+ super(ctx, env);
+ this.envVars = buildContainerEnv(env);
+ this.webhookOnly = env.OPENCLAW_WEBHOOK_ONLY === "true";
+ }
+
+ override async onActivityExpired(): Promise {
+ // Socket channels need a continuously running process. Only an explicitly
+ // webhook-only installation may let the Container helper stop the instance.
+ if (this.webhookOnly) {
+ await super.onActivityExpired();
+ }
+ }
+}
diff --git a/scripts/cloudflare/src/index.ts b/scripts/cloudflare/src/index.ts
new file mode 100644
index 000000000000..265fa7dec6ca
--- /dev/null
+++ b/scripts/cloudflare/src/index.ts
@@ -0,0 +1,29 @@
+export { OpenClawContainer } from "./container.js";
+
+interface ContainerStub {
+ fetch(request: Request): Promise;
+}
+
+interface ContainerNamespace {
+ getByName(name: string): ContainerStub;
+}
+
+interface WorkerEnv {
+ OPENCLAW_CONTAINER: ContainerNamespace;
+}
+
+interface WorkerHandler {
+ fetch(request: Request, env: WorkerEnv): Promise;
+}
+
+// One stable name gives the installation one globally unique Durable Object.
+// That object is the outer single-writer fence for the Litestream replica.
+const INSTALLATION_INSTANCE = "openclaw-installation";
+
+const worker: WorkerHandler = {
+ async fetch(request, env) {
+ return env.OPENCLAW_CONTAINER.getByName(INSTALLATION_INSTANCE).fetch(request);
+ },
+};
+
+export default worker;
diff --git a/scripts/cloudflare/tsconfig.json b/scripts/cloudflare/tsconfig.json
new file mode 100644
index 000000000000..16818a99914b
--- /dev/null
+++ b/scripts/cloudflare/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "lib": ["ES2022", "WebWorker"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "noEmit": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2022",
+ "types": []
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/scripts/cloudflare/wrangler.jsonc b/scripts/cloudflare/wrangler.jsonc
new file mode 100644
index 000000000000..49cf0033ad0c
--- /dev/null
+++ b/scripts/cloudflare/wrangler.jsonc
@@ -0,0 +1,52 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "openclaw-cloudflare",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-08-12",
+ "compatibility_flags": ["nodejs_compat"],
+ "observability": {
+ "enabled": true,
+ },
+ "vars": {
+ "LITESTREAM_BUCKET": "openclaw-backups",
+ "LITESTREAM_ENDPOINT": "https://.r2.cloudflarestorage.com",
+ "LITESTREAM_REGION": "auto",
+ // Keep false for Discord, Slack Socket Mode, WhatsApp, or any other socket channel.
+ "OPENCLAW_WEBHOOK_ONLY": "false",
+ },
+ "durable_objects": {
+ "bindings": [
+ {
+ "name": "OPENCLAW_CONTAINER",
+ "class_name": "OpenClawContainer",
+ },
+ ],
+ },
+ "migrations": [
+ {
+ "tag": "v1",
+ "new_sqlite_classes": ["OpenClawContainer"],
+ },
+ ],
+ "r2_buckets": [
+ {
+ // Documentation/Worker access only. Litestream uses the R2 S3 API and
+ // credentials supplied with `wrangler secret put`, not this binding.
+ "binding": "OPENCLAW_BACKUPS",
+ "bucket_name": "openclaw-backups",
+ },
+ ],
+ "containers": [
+ {
+ "name": "openclaw-cloudflare",
+ "class_name": "OpenClawContainer",
+ // Build scripts/cloudflare/Dockerfile for linux/amd64, publish it publicly
+ // on Docker Hub, then replace this placeholder with its immutable digest.
+ "image": "docker.io//openclaw-cloudflare@sha256:",
+ "instance_type": "standard-2",
+ "max_instances": 1,
+ // For debugging you can add `"ssh": { "enabled": true }` to allow
+ // wrangler-mediated SSH for accounts with container write access.
+ },
+ ],
+}