mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(deploy): add experimental Cloudflare deployment template (#122768)
* feat(deploy): add experimental Cloudflare template * fix(deploy): keep container SSH debug access opt-in * fix(deploy): satisfy scripts tsgo lane and model wrangler entrypoint in knip * fix(deploy): model wrangler-consumed exports and isolated dependency in knip The Worker default export and Durable Object class are instantiated by wrangler from wrangler.jsonc, and @cloudflare/containers lives in the template's isolated package.json — modeled per the deadcode checks' own guidance rather than root-manifest changes. * docs(deploy): align SSH bootstrap flow with the disabled-by-default policy
This commit is contained in:
committed by
GitHub
parent
120a75da51
commit
5fda9d09f0
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -1115,6 +1115,7 @@
|
||||
"group": "Hosting",
|
||||
"pages": [
|
||||
"install/azure",
|
||||
"install/cloudflare",
|
||||
"install/daytona",
|
||||
"install/digitalocean",
|
||||
"install/docker-vm-runtime",
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
</Warning>
|
||||
|
||||
## 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
|
||||
|
||||
<Steps>
|
||||
<Step title="Prepare the template">
|
||||
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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create R2 storage">
|
||||
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 `<account-id>` 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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Publish the Container image">
|
||||
Replace `<official-openclaw-image-digest>` 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/<docker-hub-user>/openclaw-cloudflare:<version> \
|
||||
--push \
|
||||
.
|
||||
docker buildx imagetools inspect \
|
||||
docker.io/<docker-hub-user>/openclaw-cloudflare:<version>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Deploy the Worker and Container">
|
||||
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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Set runtime secrets">
|
||||
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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Bootstrap OpenClaw">
|
||||
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 <application-id> --json
|
||||
npx wrangler containers ssh <instance-id>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 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.
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
</Warning>
|
||||
|
||||
## 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)
|
||||
@@ -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.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Cloudflare" href="/install/cloudflare">
|
||||
Experimental Worker + Container deployment.
|
||||
</Card>
|
||||
<Card title="VPS" href="/vps">
|
||||
Pick a provider.
|
||||
</Card>
|
||||
|
||||
@@ -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:<official-openclaw-image-digest>
|
||||
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"]
|
||||
@@ -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 `<account-id>` 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 `<official-openclaw-image-digest>` in `Dockerfile`, then build and push the derived image:
|
||||
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--tag docker.io/<docker-hub-user>/openclaw-cloudflare:<version> \
|
||||
--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/<docker-hub-user>/openclaw-cloudflare:<version>
|
||||
```
|
||||
|
||||
Use the resulting immutable `docker.io/<docker-hub-user>/openclaw-cloudflare@sha256:<digest>` 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 <application-id> --json
|
||||
npx wrangler containers ssh <instance-id>
|
||||
```
|
||||
|
||||
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
|
||||
@@ -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/<id>/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(/&#x([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(/<Key>([\s\S]*?)<\/Key>/g)].map((match) =>
|
||||
decodeURIComponent(decodeXml(match[1])),
|
||||
);
|
||||
const tokenMatch = xml.match(/<NextContinuationToken>([\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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<Env = unknown> {
|
||||
constructor(ctx: unknown, env: Env);
|
||||
defaultPort?: number;
|
||||
envVars: Record<string, string>;
|
||||
pingEndpoint: string;
|
||||
sleepAfter: string | number;
|
||||
fetch(request: Request): Promise<Response>;
|
||||
onActivityExpired(): Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
const containerEnv: Record<string, string> = {
|
||||
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<OpenClawContainerEnv> {
|
||||
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<void> {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export { OpenClawContainer } from "./container.js";
|
||||
|
||||
interface ContainerStub {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
}
|
||||
|
||||
interface ContainerNamespace {
|
||||
getByName(name: string): ContainerStub;
|
||||
}
|
||||
|
||||
interface WorkerEnv {
|
||||
OPENCLAW_CONTAINER: ContainerNamespace;
|
||||
}
|
||||
|
||||
interface WorkerHandler {
|
||||
fetch(request: Request, env: WorkerEnv): Promise<Response>;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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://<account-id>.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/<docker-hub-user>/openclaw-cloudflare@sha256:<derived-image-digest>",
|
||||
"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.
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user