F FourIA GitHub ↗

Fouria Operator Guide

On this page

1. Welcome

Fouria is a Cloudflare Worker that runs OpenClaw (the AI agent gateway) inside a Cloudflare Sandbox container. Each customer gets an isolated container; the Worker starts it, proxies chat traffic to it, snapshots its state to R2, and keeps it healthy.

Most of the time the system runs itself. This guide is for the moments when it does not: when a container is stuck, a backup will not restore, the AI gateway rejects requests, or the gateway will not start.

The three surfaces you operate

SurfaceEntry pointWhen to use
Health snapshotGET /api/health (and ?passive=1)Is the gateway up? What is its status?
Debug routes/debug/* (only when DEBUG_ROUTES=true)Deep inspection + emergency actions (stop, destroy, rescue, restart)
Diagnostics modulesrc/diagnostics.tsThe code that powers container rescue; gateway restart lives in src/gateway/process.ts + src/routes/debug.ts

Mental model: the lifecycle

Request / cron / health probe


 ensureGateway()  ── finds or starts the container


 start-openclaw.sh  (onboard → moltlazy patch → plugins → gateway run)


 OpenClaw gateway  (port 18789, Control UI + WebSocket)


 createSnapshot() → R2  (state preserved between restarts)

When any link in this chain breaks, the tools in this guide are your recovery path.


2. Getting Started

2.1 Prerequisites

You should be comfortable with:

  • Reading a JSON health response (curl … | jq).
  • Cloudflare concepts: Workers, Containers, R2, AI Gateway.
  • That the gateway runs inside a container — most “server is down” issues are really “container is down or the gateway process inside it is down”.

2.2 Enabling diagnostics — how to turn on debug options

Debug tooling is off by default in production for safety. There are two layers:

Layer A — DEBUG_ROUTES (the /debug/* HTTP surface)

The /debug/* routes are gated by the DEBUG_ROUTES environment variable (apps/fouria/src/index.ts:379, src/types.ts:34).

# In .dev.vars (local) or as a Worker var (production)
DEBUG_ROUTES=true
  • DEBUG_ROUTES !== 'true' → every /debug/* call returns 404 immediately (index.ts:292).
  • Setting it is non-destructive: it only exposes the inspection/action endpoints; it does not change runtime behavior.

⚠️ Never leave DEBUG_ROUTES=true on a tenant-facing deployment. It exposes /debug/gateway-token, /debug/stop-gateway, and /debug/destroy-container. Use it on operator/ROOT deployments only.

Layer B — passive health probes (no extra config)

The public health endpoint GET /api/health is always on. Append ?passive=1 to inspect a down instance without warming/cold-starting it (routes/public.ts:163):

curl "https://<worker>/api/health?passive=1" | jq .

Passive probes use a short 3-second bound (PASSIVE_PROBE_TIMEOUT_MS, config.ts:24) and never trigger ensureGateway, so a dead container fails fast instead of spinning up for minutes.

2.3 Your first diagnostic call

# 1. Is the gateway healthy? (fast, no warm-up)
curl "https://<worker>/api/health?passive=1" | jq '.status, .degraded, .containerError'

# 2. Full active snapshot (warms the container, schedules a backup)
curl "https://<worker>/api/health" | jq .

# 3. Deep dive (requires DEBUG_ROUTES=true)
curl "https://<worker>/debug/version" | jq .
curl "https://<worker>/debug/processes?logs=true" | jq .

Read the status field — it is one of running | starting | stopped (health-probes.ts:67). Read degraded — when true, a permanent container error (e.g. deleted image) requires a rescue (see §6.1).


3. Operation Guidelines — Debug Options & How to Activate Them

3.1 The debug endpoint catalog

All under /debug/*, active only when DEBUG_ROUTES=true. Each maps to logic in src/diagnostics.ts or gateway/process.ts.

Method & PathWhat it doesBacked by
GET /debug/versionOpenClaw + Node versions in the containersandbox.startProcess
GET /debug/processes?logs=trueList container processes (+ logs); returns 503 { status: "platform_updating" } while the sandbox runtime is being updated (deploy)sandbox.listProcesses
GET /debug/gateway-apiProbe the OpenClaw HTTP APIGATEWAY_API_TIMEOUT_MS
GET /debug/cliRun an OpenClaw CLI commandsandbox.exec
GET /debug/logsContainer stdout logssandbox
GET /debug/envSanitized environment (secrets masked)maskEnvValues()
GET /debug/container-configOpenClaw config from inside containersandbox.readFile
GET /debug/gateway-logsGateway stdout (/tmp/gateway-stdout.log)sandbox.exec
GET /debug/gateway-tokenThe gateway auth tokenresolveSecret
GET /debug/gateway-lockOpenClaw native lock stategateway/lock.ts
POST /debug/stop-gatewayKill gateway without restartingkillGateway()
POST /debug/restart-openclawHard gateway restart (kill → clear startup cache → flag R2 restore → start-openclaw.sh reprovision)killGateway() + resetStartupCache() + signalRestoreNeeded()
POST /debug/reprovisionForce a container reprovision (destroy → clear startup/persistence caches → reset orphan streak); never touches the gatewaysandbox.destroy() + clearPersistenceCache() + resetStartupCache()
POST /debug/destroy-containerDestroy the container entirelysandbox.destroy()
POST /debug/rescue-containerDiagnose + rescue a stuck containerrescueContainer()
POST /debug/r2-put?key=Write raw content to R2 (tests)BACKUP_BUCKET.put

Secret safety: GET /debug/env runs every value through maskEnvValues() (diagnostics.ts:31). Any key matching token, api_key, password, secret, credential, auth, or private_key is replaced with ••••••••. Never paste raw /debug/env output into tickets.

3.2 Reading the gateway lock (the most common “ghost” outage)

OpenClaw enforces single-instance ownership via three lock layers — a state lock, a config lock, and an exclusive TCP bind on port 18789 (gateway/lock.ts:1, docs.openclaw.ai/gateway/gateway-lock). A stale lock is the #1 cause of "gateway already running (pid …)" / EADDRINUSE on restart.

curl "https://<worker>/debug/gateway-lock" | jq '.state'

state is one of (lock.ts:27):

StateMeaningOperator action
freeNo lock held — safe to startNone
held-liveA live gateway owns the portAdopt it; do not kill
stale-reclaimableLock files present but owner PID goneAuto-reclaimed on next start; restart is safe
conflictLock error seen in the logWait/restart; avoid parallel starts
unknownProbe failed (container off)Check container health first

The hard restart path checkpoints state before killing the gateway, then waits for the next activation to restore that committed snapshot. OpenClaw’s native lock protects gateway ownership, but does not serialize filesystem mutations performed by onboarding, plugins, configuration, or backup operations.

3.3 Emergency actions — when to use each

ActionUse whenRisk
POST /debug/stop-gatewayGateway wedged but config is fine; you want to manually start freshLow — does not destroy data
POST /debug/restart-openclawConfig valid but gateway process dead; you want a clean in-place restartLow
POST /debug/reprovisionContainer lost/orphaned (e.g. destroyed from the dashboard) but the DO is alive; gateway RPCs are timing outMedium — forces a cold start (R2 restore keeps state)
POST /debug/destroy-containerYou want the platform to provision a brand-new container (fresh state)High — loses in-memory + unsnapshotted state
POST /debug/rescue-containerContainer is in Error mode / image deleted (see §6.1)Medium — destroys stuck instance, keeps R2 backups

3.4 The two diagnostic operations you will script most

Both live in src/diagnostics.ts and accept a dryRun option — always run dry first.

POST /debug/restart-openclaw — checkpointed hard gateway restart

Exposed as the diagnostic dashboard button (ROOT, DEBUG_ROUTES=true). It is the hard restart path (the opposite of the Admin API’s soft reload):

  1. Find the live gateway process via findExistingGatewayProcess(sandbox, { probeLive: true }).
  2. Create and commit a snapshot of /home/openclaw.
  3. killGateway(sandbox)SIGTERM with GATEWAY_KILL_GRACE_MS (5s) grace, SIGKILL only if still active; uses the [o]penclaw pkill bracket trick (no blanket "openclaw" pkill, no manual lock-file deletion — respects OpenClaw’s own single-instance lock).
  4. resetStartupCache() — clears the startup health cache.
  5. signalRestoreNeeded(BACKUP_BUCKET) — flags R2 restore so the next request reprovisions a fresh gateway via start-openclaw.sh.

If the checkpoint fails, the gateway is not killed. There is no direct binary launch; reprovisioning always goes through start-openclaw.sh.

The Admin API POST /api/admin/gateway/restart is the soft reload: it sends SIGUSR1 to the live openclaw gateway run process (softReloadGateway), then resetStartupCache(), and only falls back to signalRestoreNeeded() when no live gateway is found. OPENCLAW_NO_RESPAWN=1 (Dockerfile) makes the gateway reload in-process, so soft reload never spawns an orphan.

POST /debug/reprovision — force a container reprovision

The escape hatch for an orphaned/dead container whose Sandbox Durable Object is still alive — most commonly after the instance was destroyed from the Cloudflare Containers dashboard, which leaves the DO pointing at a placement that no longer exists.

  1. clearPersistenceCache() / resetStartupCache() — drop the per-isolate “already restored / startup ok” facts about the old container.
  2. clearProvisioningFailures(sandbox) — reset the orphaned-instance failure streak.
  3. sandbox.destroy() — tear down the instance so the next RPC provisions a fresh one.

Unlike restart-openclaw, this never talks to the gateway, so it still works when the container is completely gone and gateway RPCs are timing out.

curl -X POST "https://<worker>/debug/reprovision" | jq .

rescueContainer(sandbox, env, options)diagnostics.ts:296

Recovers a container stuck in Error mode (e.g. the fouria:unstable-<sha> image it references was deleted by the stale-image cleanup cron). Sequence:

  1. Probe container health (probeContainerHealth, short PASSIVE_PROBE_TIMEOUT_MS bound). Healthy → { action: 'none', ok: true }.
  2. Repair image via the Cloudflare Containers API (createContainersApiClientrolloutImage with strategy: 'full_auto') if credentials are present and the current image ≠ expected (diagnostics.ts:339).
  3. Destroy the stuck container (sandbox.destroy()) so the platform provisions a fresh instance against the repaired image.
# Diagnose first (dry run — does nothing)
curl -X POST "https://<worker>/debug/rescue-container" \
  -H 'content-type: application/json' -d '{"dryRun":true}' | jq .

# Actually rescue (requires CLOUDFLARE_API_TOKEN + CONTAINER_APPLICATION_ID)
curl -X POST "https://<worker>/debug/rescue-container" \
  -H 'content-type: application/json' -d '{"dryRun":false}' | jq .

The result includes actionnone | destroy | repair-image | repair-image-and-destroy | error, a diagnosis (health + errorClass), and the image report (current, expected, repaired, rolloutId).

Error classification (what “permanent” vs “transient” means)

classifyContainerError() (diagnostics.ts:166) decides whether retrying helps:

  • Permanent (no point retrying): no such image, no matching app, no namespace configured, did not call start, out of memory, resource exhaustion, pid limit. → a rescue (image repair + destroy) is required.
  • Transient (recovers on its own): connection refused, econnrefused, etimedout, timed out, not mapped, no container instance available. → just wait / warm up.
  • unknown → treat as transient unless health stays red.

This classification drives the degraded flag on the health snapshot (health-probes.ts:281): a permanent error is what flips degraded:true and tells you a rescue is needed.


4. Working with Backups

State lives in the container at /home/openclaw and is preserved as squashfs snapshots in R2 (persistence.ts). The gateway restarts from the latest snapshot on boot.

4.1 The persistence model

ConceptWhereNotes
Backup store handlebackup-handle.json in R2Tracks up to maxVersions (default 3)
Backup objectsbackups/<id>/data.sqsh[.enc], meta.json.enc when encrypted at rest
Config versionsconfigs/<timestamp>.jsonKept 30 days (CONFIG_RETENTION_DAYS)
Restore markerrestore-needed keySignals a restore on next boot
Lockbackup-lock (60s TTL)Prevents concurrent scheduled backups

4.2 Encryption at rest (F4E1)

When BACKUP_ENCRYPTION_KEY is set, every snapshot is post-encrypted with AES-256-GCM before it lands in R2 (persistence.ts:405, createSnapshot). Config versions under configs/ are encrypted with customMetadata.encrypted='true'.

# Generate a key (store it as a secret_text binding, NEVER in .dev.vars for prod)
openssl rand -base64 32
  • BACKUP_ENCRYPTION_KEY unset → backups are stored UNENCRYPTED (warned in logs).
  • BACKUP_ENCRYPTION_REQUIRED=true → backups fail closed if the key is missing (persistence.ts:419). Recommended in production.

Legacy plaintext backups remain restorable; decryption is transient and deleted in a finally block (persistence.ts:223). See docs/plans/r2-encryption-at-rest.md.

4.3 How backups happen

  • ScheduledscheduleBackupIfNeeded() runs (interval-gated, default every 5 min via BACKUP_INTERVAL_MINUTES) each time /api/health is called while not passive (public.ts:197). Guarded by a 60s R2 lock.
  • On hard restartPOST /debug/restart-openclaw first creates a checkpoint, then clears the startup cache and flags R2 restore.
  • ManualPOST /api/admin/storage/sync calls createSnapshot().
  • POST /debug/r2-put only writes test objects; it does not create a snapshot.

Automatic backup trigger state machine

Since the Worker cron was removed, the only automatic snapshot trigger is a non-passive GET /api/health — in practice the lerma HealthMonitor (apps/lerma/lib/lerma/provisioning/health_monitor.ex, default every 120s). Everything else in the state machine is a gate that must pass before an object lands in R2.

stateDiagram-v2
    direction TB

    [*] --> Idle

    state "Idle (no snapshot scheduled)" as Idle
    state "Non-passive GET /api/health" as Probe
    state "Interval elapsed?" as IntervalGate
    state "backup-lock free?" as LockGate
    state "Snapshotting (sandbox.createBackup)" as Snapshot
    state "Encrypting at rest (F4E1)" as Encrypt
    state "Committing catalog + pruning" as Commit
    state "No backup written" as NoBackup

    Idle --> Probe : lerma HealthMonitor (2 min; production/error clients only)
    Idle --> Snapshot : hard restart checkpoint (POST /debug/restart-openclaw)
    Idle --> Snapshot : manual sync (POST /api/admin/storage/sync)

    Probe --> Idle : ?passive=1 skips backups
    Probe --> IntervalGate : non-passive
    IntervalGate --> Idle : < BACKUP_INTERVAL_MINUTES since last
    IntervalGate --> LockGate : interval elapsed and BACKUP_BUCKET bound
    LockGate --> Idle : backup-lock held (< 60s TTL)
    LockGate --> Snapshot : lock acquired

    Snapshot --> NoBackup : InvalidBackupConfigError / R2 error
    Snapshot --> Encrypt : archive created
    Encrypt --> NoBackup : BACKUP_ENCRYPTION_REQUIRED and key missing
    Encrypt --> Commit : encrypted (.enc) or plaintext (warned)
    Commit --> Idle : backup-handle.json written, oldest pruned
    NoBackup --> Idle : lock released

The same state machine as an Eraser flowchart. Render it by POSTing the DSL to the Eraser render API (see the eraser-diagrams skill); it is kept inline so the diagram can be regenerated without an API key.

direction down

Start [shape: oval]
Idle [shape: rectangle, label: "Idle - no snapshot scheduled"]
Probe [shape: rectangle, label: "Non-passive GET /api/health"]
HardRestart [shape: rectangle, label: "Hard restart checkpoint"]
ManualSync [shape: rectangle, label: "Manual sync"]
PassiveSkip [shape: oval, color: gray, label: "Skip (passive=1)"]
IntervalGate [shape: diamond, label: "Interval elapsed?"]
LockGate [shape: diamond, label: "backup-lock free?"]
Snapshot [shape: rectangle, label: "Snapshotting (sandbox.createBackup)"]
Encrypt [shape: rectangle, label: "Encrypting at rest (F4E1)"]
Commit [shape: rectangle, label: "Commit catalog + prune"]
NoBackup [shape: oval, color: red, label: "No backup written"]

Start > Idle
Idle > Probe : "HealthMonitor (2 min; production/error clients)"
Idle > HardRestart
Idle > ManualSync
Probe > PassiveSkip : "passive=1"
Probe > IntervalGate : "non-passive"
IntervalGate > Idle : "too soon"
IntervalGate > LockGate : "yes + BACKUP_BUCKET bound"
LockGate > Idle : "locked (<60s TTL)"
LockGate > Snapshot : "acquired"
HardRestart > Snapshot
ManualSync > Snapshot
Snapshot > NoBackup : "InvalidBackupConfigError / R2 error"
Snapshot > Encrypt : "archive created"
Encrypt > NoBackup : "encryption required, no key"
Encrypt > Commit : "encrypted or plaintext warned"
Commit > Idle : "catalog written, oldest pruned"
NoBackup > Idle : "lock released"
Automatic triggerEntry pointGates that must pass
Scheduled snapshotnon-passive GET /api/health (public.ts:200)lerma HealthMonitor must poll the tenant; BACKUP_BUCKET bound; BACKUP_INTERVAL_MINUTES elapsed; 60s lock free; SDK presigned creds present (or DEV_MODE local bucket)
Hard-restart checkpointPOST /debug/restart-openclawnone (unconditional checkpoint before kill)
Manual snapshotPOST /api/admin/storage/synccaller authorized (ADMIN/ROOT)

Two operator traps that leave the bucket empty even though the worker is healthy.

First, HealthMonitor.list_monitored/0 only probes clients whose status is production or error — a tenant left in draft (or any other status) is never probed, so no scheduled backup ever fires.

Second, the Sandbox SDK (>= 0.12.9) createBackup() on the remote path calls requirePresignedURLSupport(), which throws InvalidBackupConfigError unless R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, BACKUP_BUCKET_NAME, and CLOUDFLARE_ACCOUNT_ID are all set on the tenant worker. The failure is caught by scheduleBackupIfNeeded() and logged as [persistence] Scheduled backup failed: — the R2 bucket then contains only backup-handle.json (or nothing at all).

Verify both before assuming backups are running.

To verify the R2 backup bucket is reachable without triggering a snapshot, use POST /api/admin/storage/test-connectivity (ADMIN or ROOT). It HEADs the backup catalog handle; a 404 (no backup written yet) still counts as connected. ROOT users can run it from the Diagnostic Dashboard’s Test Backup Connectivity button even though the Backups page is ADMIN-gated.

The number of retained versions is BACKUP_MAX_VERSIONS (default 3); oldest beyond that are pruned (persistence.ts:306).

4.4 Restoring

  • AutomaticrestoreIfNeeded() runs at container startup; it finds the newest valid backup and restores it. A missing/again-missing restore-needed marker is the signal.
  • TargetedrestoreBackupById(sandbox, bucket, backupId, env) restores a specific snapshot (persistence.ts:236). Use the backup history + config version list to pick one.

Each entry in backup-handle.json records whether it was created with the SDK’s localBucket mode (persistence.ts). That flag is forwarded to restoreBackup() so a DEV_MODE (local-binding) backup restores through the local R2 binding instead of the remote presigned-URL path — a backup created locally only exists in the local bucket, so a remote restore 404s with Presigned URL download failed (exit code 22).

Entries created before the flag was persisted carry no localBucket; on restore it is defaulted from the same DEV_MODE signal used at create time, so legacy local backups still restore via the local binding. A restore that 404s at the presigned URL (archive gone / unreachable) is treated like BACKUP_NOT_FOUND — the stale entry is dropped from the catalog so one dead backup cannot block every subsequent boot.

Operator workflow for “I need to roll back to a known-good config”:

  1. List config versions: GET /api/admin/storage/config (or inspect configs/ in R2).
  2. Confirm the snapshot you want exists in backup-handle.json’s backups[].
  3. If the container is up, stop the gateway (POST /debug/stop-gateway), then call a targeted restore, then restart. If the container is down, the next boot auto-restores the newest.

⚠️ The current implementation uses the Sandbox backup API and the BACKUP_BUCKET R2 binding. It does not mount R2 with s3fs. Never delete tenant backup objects outside the retention or decommission procedure.

4.5 Setting up R2 (one-time)

Scheduled backups run when the Worker has its BACKUP_BUCKET binding and the interval/lock guards pass (scheduleBackupIfNeeded() in persistence.ts). The bound bucket is provisioned per tenant, for example fouria-backup-{slug}{-environment}{-platform}.

The Sandbox SDK remote path requires R2 S3 credentials. In @cloudflare/sandbox >= 0.12.9, createBackup() (and restoreBackup()) on the non-localBucket path calls requirePresignedURLSupport(), which throws InvalidBackupConfigError unless all of R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, CLOUDFLARE_ACCOUNT_ID, and BACKUP_BUCKET_NAME are present on the tenant Worker. hasBackupCredentials() only gates on BACKUP_BUCKET, so a misconfigured worker still runs the scheduler and then fails at snapshot time (logged as [persistence] Scheduled backup failed:) with an empty bucket. Only DEV_MODE=true (the local R2 binding path) avoids the credential requirement.

  1. Bind the tenant bucket — the Lerma provisioner creates and binds the tenant R2 bucket as BACKUP_BUCKET and sets BACKUP_BUCKET_NAME.

  2. Let lerma resolve it (it is now mandatory) — the provisioner resolves a credential per instance and fails provisioning (rolling back) when none can be produced; it no longer warns and deploys a tenant that silently cannot back up. Strategy is R2_TOKEN_STRATEGY (auto | shared | per_instance):

    • auto (default): uses R2_TOKEN_MAX_PER_ACCOUNT; a known limit of at most 250 selects shared, a larger or unknown limit selects per_instance. Cloudflare exposes no maximum-token endpoint, so the limit defaults to unknown → per-instance token minted at instance creation, scoped to the tenant bucket.
    • shared: set R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY in the lerma environment (generate with mix lerma.r2_token --all-buckets).

    Generated per-instance credentials are persisted on the instance record and reused across redeploys; a token minted for a failed brand-new provisioning is deleted on rollback. CLOUDFLARE_ACCOUNT_ID is a plain var and is always injected.

  3. Ensure the client is being probed — the lerma HealthMonitor is the only automatic trigger and only polls clients in production/error. A client in draft gets no scheduled snapshots regardless of credentials.

    # For a standalone/legacy Worker (not provisioned via lerma):
    npx wrangler secret put R2_ACCESS_KEY_ID
    npx wrangler secret put R2_SECRET_ACCESS_KEY

Sandbox snapshot behavior differs between deployed and local environments; local tests use the SDK’s localBucket mode when DEV_MODE=true (persistence.ts:398). The flag is persisted in the backup catalog so restores route to the same mode — a local backup restores via the local R2 binding, and a remote (presigned-URL) backup restores via R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / CLOUDFLARE_ACCOUNT_ID / BACKUP_BUCKET_NAME. Pre-persist entries default to the current DEV_MODE. There is no s3fs mount.


5. Troubleshooting — Critical Issues

This section maps symptoms → root cause → the exact recovery from this guide.

5.1 Container stuck in Error / image deleted → RESCUE

Symptom: /api/health?passive=1 returns degraded:true, containerError:"no such image" (or no matching app). The instance can never boot.

Why: the stale-image cleanup cron deleted the fouria:unstable-<sha> image the application references (diagnostics.ts:42). The cleanup script now protects every tag still referenced by a container application — including the versions of an in-progress rollout — and aborts (fail closed) when that in-use set cannot be resolved (.github/scripts/cleanup-stale-images.sh). Production deploys are also restricted to stable release tags, so a production application can no longer be pinned to the unstable-<sha> channel (Lerma.Cloudflare.ImageRegistry.stable?/1).

Fix: rescueContainer (§3.4):

curl -X POST "https://<worker>/debug/rescue-container" -d '{"dryRun":true}' | jq .
# confirm action would be repair-image-and-destroy, then:
curl -X POST "https://<worker>/debug/rescue-container" -d '{"dryRun":false}' | jq .

Credentials required: CLOUDFLARE_API_TOKEN (Containers:Edit), CONTAINER_APPLICATION_ID, and either CONTAINER_IMAGE_TAG or an explicit expectedImage (diagnostics.ts:304, .dev.vars.example:43). Without them, the image cannot be repaired — only the container is destroyed (you then must re-deploy the correct image tag).

NEVER set these on tenant workers — the rescue tool re-points the application image and is operator/ROOT-only (.dev.vars.example:40).

5.2 Gateway “already running” / EADDRINUSE on restart

Symptom: restart fails with gateway already running (pid …) or failed to bind gateway socket.

Why: a previous gateway still holds the native lock / TCP bind when the new one starts (gateway/process.ts).

Fix: for a clean state use killGateway() (the POST /debug/stop-gateway route), which SIGTERMs with the GATEWAY_KILL_GRACE_MS (5s) grace and only SIGKILLs if still active, then wait for the port to close before reprovisioning. POST /debug/restart-openclaw (hard restart) does this then reprovisions via start-openclaw.sh. Inspect lock state with GET /debug/gateway-lock.

5.3 Gateway process invisible but container live

Symptom: listProcesses() shows nothing, yet /health answers.

Why: the gateway runs as a detached child of start-openclaw.sh (process.ts:427). The SDK does not track it, but it is healthy.

Fix: the live-gateway fallback (findExistingGatewayProcess with probeLive:true) detects it via a bounded /health probe. Prefer restart/rescue APIs that use this path rather than destroy-container (which would throw away a healthy instance).

5.4 Backup restore loops / “hadBackup but not restored”

Symptom: health startup.ok=false permanently with Backup found but not restored.

Why: restoreIfNeeded found an entry but every backup expired/was missing (persistence.ts:212). The code explicitly reports a clean start in this case so it does not gate startup forever (persistence.ts:233).

Fix: confirm real backups exist (backup-handle.jsonbackups[]); if none, the instance boots fresh (expected). If backups exist but restore fails, check BACKUP_ENCRYPTION_KEY (encrypted backups cannot be read without it).

5.5 Platform & setup troubleshooting

SymptomLikely causeAction
npm run dev / deploy: UnauthorizedCloudflare Containers not enabled for the accountEnable Containers in the Containers dashboard
[sandbox-do] backup.restore … Presigned URL download failed (exit code 22) … 404restore hit the remote presigned-URL path for an archive that only exists in the local R2 binding (a DEV_MODE/localBucket backup), or BACKUP_BUCKET_NAME doesn’t match the BACKUP_BUCKET binding bucketredeploy so the backup catalog persists localBucket (§4.4) — local backups restore via the local binding; for remote backups verify R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/CLOUDFLARE_ACCOUNT_ID/BACKUP_BUCKET_NAME all point at the same bucket as the BACKUP_BUCKET binding (§4.5)
Gateway won’t startmissing/incorrect secretsnpx wrangler secret list + npx wrangler tail
Config changes not taking effectstale Docker build cachebump the # Build cache bust: comment in Dockerfile, then redeploy
Slow first requestcold start (1–2 min)expected; later requests are faster
R2 not mountingan R2 secret missing, or running wrangler devset all four R2 secrets (§4.5); R2 mount is production-only
Access denied on /_admin or /apiCF_ACCESS_TEAM_DOMAIN / CF_ACCESS_AUD not setconfigure Cloudflare Access (§8.1)
Devices not appearing in admin UIdevice-list CLI takes 10–15 s (WS overhead)wait and refresh
WebSocket issues in local devwrangler dev WS-proxy limitationdeploy for full WS functionality
Windows: script exits 126CRLF line endings in shell scriptsadd .gitattributes with * text=auto eol=lf

5.6 Deploy-window “Could not list processes” / platform interrupted

Symptom: logs show [Gateway] Could not list processes: followed by a stack trace ending in createPlatformInterruptedError / translatePlatformInterruption, and/or /debug/processes returns a 503 with { "status": "platform_updating" }.

Why: the Sandbox SDK throws OperationInterruptedError (“Sandbox operation listProcesses was interrupted while the platform was updating the sandbox runtime”) when the Durable Object isolate is replaced — exactly what happens during a deploy / code update. It is a transient platform lifecycle event, not an application failure, and it self-heals on the next request once the new isolate is live.

Fix: nothing to fix. In findExistingGatewayProcess this is logged as a single concise “platform was updating the sandbox runtime” line instead of a raw stack trace; /debug/processes returns 503 platform_updating so debug tooling doesn’t misreport a crash. If it persists beyond the deploy window, re-check wrangler tail and the container instance health in the Containers dashboard.

5.7 Container terminated/destroyed → every request 500s or burns a cold-start budget

Symptom A — platform-terminated: the platform SIGTERMed the container (idle recycle, maintenance, rescue) and after that all requests fail with [Gateway] Could not list processes: plus sandbox.exec error … HTTP error! status: 500 — the container is dead and nothing ever restarts it. The dashboard shows “Could not list processes” and the UI is fully bricked.

Symptom B — out-of-band destroyed (orphaned): the instance was destroyed from the Cloudflare Containers dashboard (or a failed operator force-stop). The Sandbox DO is still alive but points at a placement that no longer exists, so every request logs around no container instance available / maximum number of running container instances exceeded and each request burns the SDK’s full provisioning retry budget (≈20s instance + 90s port) before giving up — CPU is spent repeatedly trying to reach a container that can never be admitted, and the UI never recovers.

Why: the Worker holds a Sandbox stub for a DO whose container instance is gone. Retrying any RPC against that instance fails forever, and the old code swallowed the error and returned null instead of reprovisioning — so no fresh container was ever created (gateway/sandbox-recovery.ts).

Fix (automatic): maybeRecoverSandbox classifies both failure classes and calls recoverStaleSandbox (destroy the dead instance + reset the startup/persistence caches + reset the orphan streak). The next RPC auto-provisions a fresh container and the normal cold-start flow runs (R2 restore → start-openclaw.sh). Wired into findExistingGatewayProcess, checkGatewayHealth, and the ensureGateway startProcess retry (gateway/process.ts).

  • Stale (HTTP error! status: 500) recovers immediately (isStaleContainerError).
  • Orphaned (no container instance available) is retryable on its own, so it is only treated as an orphan once the failures span ORPHANED_INSTANCE_GRACE_MS (120s) — deliberately longer than the SDK’s ≈110s provisioning budget so a legitimately slow cold start is never cancelled mid-provision. The streak is cleared whenever a sandbox RPC succeeds (the container is demonstrably alive), so a healthy instance never trips the threshold later.

Recoveries are cooldown-guarded (STALE_RECOVERY_COOLDOWN_MS, 60s) to prevent destroy-storms during request floods. Look for [SandboxRecovery] and repeated provisioning failures lines in wrangler tail. Manual fallback: POST /debug/reprovision (works even when the gateway is gone), or restart the container from the Containers dashboard.

NOT covered: permanent image errors (no such image) still require the rescue tool (§5.1), and deploy-window OperationInterruptedError is expected and self-heals (§5.6).


6. Subsystem Deep-Dives

6.1 Working with Cloudflare Containers

  • Image reference is built as registry.cloudflare.com/<accountId>/<name>:<tag> (buildImageReference, diagnostics.ts:213). name defaults to fouria (DEFAULT_CONTAINER_IMAGE_NAME).
  • Rescue needs the Containers API (/accounts/{account}/containers/applications/..., createContainersApiClient, diagnostics.ts:235). It uses Authorization: Bearer <CLOUDFLARE_API_TOKEN> and a full_auto rollout.
  • Timeouts that keep a degraded/off container from hanging: CONTAINER_INSTANCE_TIMEOUT_MS (20s), CONTAINER_PORT_TIMEOUT_MS (90s — kept generous for cold starts), CONTAINER_POLL_INTERVAL_MS (300ms) (config.ts:33). Override via SANDBOX_INSTANCE_TIMEOUT_MS / SANDBOX_PORT_TIMEOUT_MS / SANDBOX_POLL_INTERVAL_MS (.dev.vars.example:78).

Container cost & lifecycle

The tenant container is a standard-1 instance (½ vCPU, 4 GiB memory, 8 GB disk). Approximate 24/7 monthly cost (per Cloudflare Containers pricing):

ResourceProvisionedApprox. cost
Memory (4 GiB)billed 24/7~$26/mo
CPU (½ vCPU, ~10% used)usage-based~$2/mo
Disk (8 GB)billed 24/7~$1.50/mo
Workers Paid planflat$5/mo
Total~$34.50/mo

Notes:

  • CPU is billed on active usage, not provisioned capacity. Memory/disk are billed on provisioned capacity for the full uptime.

  • Cold starts take 1–2 minutes; the first request after a sleep/wake is slow.

  • Reduce cost with SANDBOX_SLEEP_AFTER — the container sleeps when idle instead of running 24/7. A container used ~4 h/day drops to roughly $5–6/mo compute + the $5 plan fee.

    npx wrangler secret put SANDBOX_SLEEP_AFTER   # e.g. 10m, 30m, 1h

    never (the default) keeps it alive indefinitely (gateway/process.ts:689).

6.2 R2 issues

SymptomLikely causeAction
Backup stored UNENCRYPTED warningBACKUP_ENCRYPTION_KEY unsetSet the key (secret_text binding)
Backup fails closedBACKUP_ENCRYPTION_REQUIRED=true, key missingProvide the key
Restore of encrypted backup failskey mismatch / missingEnsure BACKUP_ENCRYPTION_KEY matches the one used at write time
Scheduled backup never runsmissing R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / BACKUP_BUCKET_NAME / CLOUDFLARE_ACCOUNT_IDSandbox SDK presigned-URL path needs all four; hasBackupCredentials (persistence.ts) gates the scheduler, requirePresignedURLSupport throws InvalidBackupConfigError at snapshot time
Old config versions pile upretention is 30 days; normalcleanupOldConfigVersions prunes automatically

R2 persistence uses the Sandbox SDK backup API; there is no s3fs mount to inspect. Verify backup state with GET /api/admin/storage/backups and never delete tenant backup objects outside retention or decommission procedures.

6.3 AI Gateway issues

The only provisioned AI provider path is Cloudflare AI Gateway, configured when all three are set (start-openclaw.sh:161, config.ts:97):

npx wrangler secret put CLOUDFLARE_AI_GATEWAY_API_KEY   # your gateway API key
npx wrangler secret put CF_AI_GATEWAY_ACCOUNT_ID        # Cloudflare account id
npx wrangler secret put CF_AI_GATEWAY_GATEWAY_ID        # gateway id within the account

Cloudflare AI Gateway gives caching, rate limiting, analytics, and cost tracking, and the cloudflare-unified-billing plugin extends the catalog to 150+ models (Google Gemini, Anthropic Claude, OpenAI, xAI Grok, DeepSeek, Alibaba Qwen, MiniMax, Moonshot Kimi, and more) via the AI Gateway REST API. Lerma provisions the AI-scoped credential to tenant workers as both CLOUDFLARE_AI_GATEWAY_API_KEY and the REST alias CLOUDFLARE_API_TOKEN.

Premade agents:

Production deployments ship no premade agents — only the main agent written by openclaw onboard. The dev-only Fouria Builder and Fouria Q&A agent are injected by moltlazy when DEV_MODE=true (they can self-install plugins and read per-tenant client documentation materialized from R2).

AgentEnvironmentPrimary modelPurpose
fouria-builderdev onlyClaude Sonnet 4.6Read client proposal/phases, build a working POC
fouria-qadev onlyClaude Sonnet 4.6Run cctr corpus validation against the Builder’s POC
SymptomCauseAction
Onboard skips AI Gateway authone of the three vars missingset all three; mark NEED_ONBOARD=true
cloudflare-ai-gateway plugin missinginstall timed out (network)warm restore keeps it; cold start reinstalls (bounded, non-fatal)
Model requests fail with auth errorswrong/expired gateway keyrotate CLOUDFLARE_AI_GATEWAY_API_KEY
No embedding / memory search errorsno embedding key (CF_AI_GATEWAY_OPENAI_COMPAT_KEY)memory search auto-disabled (start-openclaw.sh:422) — expected

The cloudflare-unified-billing plugin (40+ models via the REST API) is enabled alongside; its enablement is written by the batched config patch, not a plugins enable CLI call, to avoid a hanging ClawHub registry refresh (start-openclaw.sh:300).

When migrating existing tenants from the old provisioner, re-provision the tenant and perform a cold restart after the AI credential is configured. Older R2 snapshots may contain a stale native-provider auth profile and the old random AI binding; a warm restore alone does not replace those persisted values.

6.4 Startup process issues

The startup sequence is start-openclaw.sh (STEPS array, start-openclaw.sh:69): init → onboard → plugins → config-patch → config-validate → tunnel → gateway. Watch it live via:

curl "https://<worker>/api/health"        # status: starting while script runs
curl "https://<worker>/debug/gateway-logs" | tail -n 50
SymptomCauseAction
Stuck at config-validate → startup abortsinvalid generated configmoltlazy validate locally; fix feature flags
Stuck at onboard > 60sAI Gateway plugin not preinstalledthe cloudflare-ai-gateway provider is preinstalled before onboard (so its plugin phase is a no-op); if it still times out, check ClawHub/npm reachability — onboard is capped at 60s and continues, gateway starts --allow-unconfigured
Gateway dies before port opensgateway binary crashGET /debug/gateway-logs for the crash; check EADDRINUSE
STARTUP_TIMEOUT_MS (300s) exceededplugin installs slowcold start is bounded; warm restores are faster
Loading page never advancesstartup-progress.json not writtencheck /tmp/startup.log via debug/gateway-logs

The loading page renders from /tmp/startup-progress.json + /tmp/startup.log (routes/public.ts:131, read bounded by STARTUP_PROGRESS_TIMEOUT_MS).


8. Cloudflare Platform Setup

One-time operator setup tasks for the current FourIA Worker deployment.

8.1 Cloudflare Access (protects /_admin, /api, /debug)

  1. Enable Access on the worker — Workers & Pages dashboard → your Worker → Settings → Domains & Routes → workers.dev → Enable Cloudflare Access. Copy the Application Audience (AUD) tag.

  2. Configure who can access — Zero Trust → Access → Applications → your worker app → add allowed identities (email, Google, GitHub, …).

  3. Set the secrets:

    npx wrangler secret put CF_ACCESS_TEAM_DOMAIN   # e.g. myteam.cloudflareaccess.com
    npx wrangler secret put CF_ACCESS_PLATFORM_AUD  # platform AUD (always required)
    npx wrangler secret put CF_ACCESS_CLIENT_AUD    # optional per-tenant AUD

    Alternatively create a Self-hosted Access application and protect paths /_admin/*, /api/*, /debug/*.

/debug/* must be Access-protected. Never expose it publicly — it can stop, destroy, and rescue containers.

8.2 Browser Automation (CDP)

A Chrome DevTools Protocol shim for browser automation. Set:

npx wrangler secret put CDP_SECRET   # shared secret for CDP auth
npx wrangler secret put WORKER_URL   # worker public URL
npm run deploy
EndpointDescription
GET /cdp/json/versionBrowser version information
GET /cdp/json/listList available browser targets
GET /cdp/json/newCreate a new browser target
WS /cdp/devtools/browser/{id}WebSocket connection for CDP commands

All endpoints require ?secret=<CDP_SECRET>.

8.3 Obsidian Sync via Cloudflare Tunnel

Exposes the Obsidian LiveSync-compatible service (port 18790). Requires an active Cloudflare zone for your domain.

cloudflared tunnel create obsidian-sync
cloudflared tunnel token <UUID>          # then:
npx wrangler secret put CLOUDFLARE_TUNNEL_TOKEN

Add a proxied CNAME: name obsidian-sync, target <UUID>.cfargotunnel.com. Ingress (~/.cloudflared/config.yml): hostname: obsidian-sync.yourdomain.com → service: http://localhost:18790. The gateway only starts the tunnel when CLOUDFLARE_TUNNEL_TOKEN is set (start-openclaw.sh:497); otherwise obsidian-sync is localhost-only.

8.4 Built-in skills

The container ships pre-installed skills in /root/clawd/skills/, including cloudflare-browser (browser automation via the CDP shim; needs CDP_SECRET + WORKER_URL). Scripts: screenshot.js, video.js, cdp-client.js.


9. Requirements & References

Requirements

  • Workers Paid plan ($5/mo) — required for Cloudflare Sandbox containers.
  • An AI provider key via Cloudflare AI Gateway (API key, account id, gateway id) — the only supported AI path. Unified Billing is available as an alternative (docs).

Free tiers exist for Cloudflare Access, Browser Rendering, AI Gateway, and R2 Storage.

See also


7. Quick Reference

Environment variables (operator-relevant)

VariablePurposeNotes
DEBUG_ROUTESEnables /debug/*Set true only on operator/ROOT deploys
CLOUDFLARE_API_TOKENAI Gateway REST auth; Containers:Edit for rescueAI-scoped tenant alias; rescue token on ROOT/ops
CONTAINER_APPLICATION_IDTarget app for image rolloutOperator-only
CONTAINER_IMAGE_TAG / CONTAINER_IMAGE_NAMEExpected image unstable-<sha> / fouriaOperator-only
CLOUDFLARE_AI_GATEWAY_API_KEY / CF_AI_GATEWAY_ACCOUNT_ID / CF_AI_GATEWAY_GATEWAY_IDAI Gateway authRequired for AI provider
BACKUP_ENCRYPTION_KEY / BACKUP_ENCRYPTION_REQUIREDR2 encryption at restRecommended true in prod
BACKUP_BUCKET_NAME / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEYR2 persistenceAll four (incl. CLOUDFLARE_ACCOUNT_ID) required for scheduled backups; SDK signs R2 presigned URLs
BACKUP_INTERVAL_MINUTES / BACKUP_MAX_VERSIONSBackup cadence / retentionDefaults 5 / 3
SANDBOX_INSTANCE_TIMEOUT_MS / SANDBOX_PORT_TIMEOUT_MS / SANDBOX_POLL_INTERVAL_MSContainer fail-fast boundsSee §6.1
SANDBOX_SLEEP_AFTERIdle sleep to cut cost (never = always on)e.g. 10m; see §6.1 cost note
CLOUDFLARE_ACCOUNT_IDCloudflare account id (plain var)Read by the Sandbox SDK for R2 presigned URL signing; legacy CF_ACCOUNT_ID is deprecated
CF_ACCESS_TEAM_DOMAIN / CF_ACCESS_PLATFORM_AUD / CF_ACCESS_CLIENT_AUDCloudflare Access authProtects /_admin, /api, /debug
CDP_SECRET / WORKER_URLBrowser automation (CDP)See §8.2
CLOUDFLARE_TUNNEL_TOKENObsidian sync ingressSee §8.3
STARTUP_LOG_VERBOSITYsteps/detailed/verbose boot logSecret-free

One-line recovery cheatsheet

# 1. Is it up? (no warm-up)
curl "https://<worker>/api/health?passive=1" | jq '{status,degraded,containerError}'

# 2. What's the lock state?
curl "https://<worker>/debug/gateway-lock" | jq .state

# 3. Degraded (no such image)? Rescue (dry first):
curl -X POST "https://<worker>/debug/rescue-container" -d '{"dryRun":true}' | jq .
curl -X POST "https://<worker>/debug/rescue-container" -d '{"dryRun":false}' | jq .

# 4. Gateway wedged but config OK? Stop, wait 8s, restart (via Admin API).
# 5. Lost state? Inspect backups, then restore by id or let next boot auto-restore.

This guide is generated from the source in apps/fouria/src/diagnostics.ts and its dependencies (gateway/process.ts, gateway/health-probes.ts, gateway/lock.ts, persistence.ts). When the behavior of those modules changes, update this document in the same change.