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:
Peter Steinberger
2026-08-12 14:07:48 -07:00
committed by GitHub
parent 120a75da51
commit 5fda9d09f0
16 changed files with 841 additions and 2 deletions
+24
View File
@@ -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"]
+164
View File
@@ -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
+197
View File
@@ -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&region=%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("&quot;", '"')
.replaceAll("&apos;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&amp;", "&");
}
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"
+36
View File
@@ -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
+16
View File
@@ -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"
}
}
+13
View File
@@ -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>;
}
}
+74
View File
@@ -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();
}
}
}
+29
View File
@@ -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;
+15
View File
@@ -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"]
}
+52
View File
@@ -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.
},
],
}