Docker and Fly deployment
Responsibilities
Dockerfile (Dockerfile CMD:L343-L344) is the root entry for OpenClaw's containerized deployment: a multi-stage build producing a minimal runtime image that defaults to starting node openclaw.mjs gateway. fly.toml (fly.toml) is the Fly.io deployment description, mounting the Docker image onto Fly's vm + persistent volume. render.yaml (render.yaml) is the equivalent for Render.com. docker-compose.yml (docker-compose.yml) orchestrates the gateway + CLI two services, usable for local dev or self-hosting.
This deployment form complements Daemon: system service: the container has no launchd/systemd/schtasks; the container orchestrator (Docker Engine / Fly machine / Render) is the "system service manager", handling boot auto-start and crash restart. OpenClaw only needs to expose a health check endpoint + accept SIGTERM for clean exit; the rest is the orchestrator's job.
Design motivation
Why abstract containerized deployment as a separate layer?
- Minimal image: the full
node:24-bookwormimage is 1GB+; runtime only needsnode:24-bookworm-slim+ a few system packages likeca-certificates/curl/git/tini. Multi-stage build separates the build layer (Bun/pnpm install +pnpm build:docker+pnpm ui:build) from the runtime layer; the final image carries no Bun, no source code, no dev dependencies, no .d.ts/.map (prune d.ts and maps:L149). - Secure defaults:
USER node(USER node:L327) runs as non-root;cap_drop: [NET_RAW, NET_ADMIN]+security_opt: [no-new-privileges:true](cap_drop) — the container can't construct raw sockets or escalate privileges.Umask=0o077protects file permissions in daemon mode; the container achieves the same by pre-creating0700directories (install -d permissions:L312-L320). - Built-in health check:
HEALTHCHECK(HEALTHCHECK:L341-L342) directly calls the/healthzendpoint, no external probe script — the orchestrator (Docker Compose/Fly/Render) judges container liveness from this./healthzis liveness;/readyzis readiness; aliases/healthand/readyfor habit compat. - Cross-orchestrator:
fly.toml,render.yaml,docker-compose.ymlcover three deployment targets, but all use the same image — the difference is only in port/mount/env-var declaration.
Key files
Dockerfile build args:L10-L25—OPENCLAW_EXTENSIONS="",OPENCLAW_BUNDLED_PLUGIN_DIR=extensions, pinned base image digests (node:24-bookworm / bookworm-slim / Bun).Stage 1: workspace-deps:L26-L45— only copies package.json list, avoiding invalidating the main build layer on unrelated source changes.Stage 2: build:L48-L99— COPY Bun binary,corepack enable,pnpm install --frozen-lockfile, matrix-sdk-crypto native addon retry,pnpm build:docker+pnpm ui:build.matrix-sdk-crypto retry:L82-L97— 5 retries for downloading the native addon, because the matrix-sdk-crypto-nodejs CDN is sometimes unstable.runtime-assets:L135-L154—pnpm prune --prod --offline, removes dev dependencies, d.ts, .map,node_modules/openclaw(self-reference).Stage 3: runtime:L156-L189— basebookworm-slim, installsca-certificates curl git hostname lsof openssl procps python3 tini.ca-certificates install:L184-L189— not installing makes HTTPS outbound fail at TLS handshake (error setting certificate file).OPENCLAW_INSTALL_BROWSER:L252-L262— optional Playwright Chromium + Xvfb, avoiding 60-90s install on every startup.OPENCLAW_INSTALL_DOCKER_CLI:L268-L301— optional Docker CLI, needed for sandbox container management; GPG fingerprint double-check, requires exactly one pub key.CLI symlink:L304-L305—ln -sf /app/openclaw.mjs /usr/local/bin/openclaw, avoiding non-root npm global writes.pre-create directory permissions:L312-L320—install -d -m 0700 -o node -g node, thenstat -cverify, preventing root-owned directory polluting a named volume on first mount.ENV + USER + HEALTHCHECK + CMD:L322-L344—NODE_ENV=production,USER node,HEALTHCHECK3min interval +tinientrypoint +CMD ["node","openclaw.mjs","gateway"].fly.toml—app = "openclaw",primary_region = "iad",internal_port = 3000,auto_stop_machines = false(keep resident),min_machines_running = 1,shared-cpu-2x+ 2GB,openclaw_datavolume mounted to/data.render.yaml—runtime: docker,plan: starter,healthCheckPath: /health,OPENCLAW_GATEWAY_TOKEN: generateValue: true(auto-generated), 1GB disk mount/data.docker-compose.yml—openclaw-gateway+openclaw-clitwo services; CLI usesnetwork_mode: service:openclaw-gatewayto share network namespace; mounts~/.openclawand secrets directory.
Data flow
The final steps of container startup (Dockerfile tail:L322-L344) are critical:
ENV NODE_ENV=production
# Security hardening: Run as non-root user
# The node:24-bookworm image includes a 'node' user (uid 1000)
USER node
# Start gateway server with default config.
# Binds to loopback (127.0.0.1) by default for security.
#
# IMPORTANT: With Docker bridge networking (-p 18789:18789), loopback bind
# makes the gateway unreachable from the host. Either:
# - Use --network host, OR
# - Override --bind to "lan" (0.0.0.0) and set auth credentials
#
# Built-in probe endpoints for container health checks:
# - GET /healthz (liveness) and GET /readyz (readiness)
# - aliases: /health and /ready
HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["tini", "-s", "--"]
CMD ["node", "openclaw.mjs", "gateway"]ENTRYPOINT ["tini", "-s", "--"] is key — tini is a lightweight init that reaps zombie processes and forwards signals correctly to the Node process. Inside a container, PID 1 by default doesn't handle SIGCHLD; without tini, ACP/subagent subprocesses would become zombies on exit. tini also cleanly forwards the SIGTERM from docker stop to Node, letting the gateway shut down cleanly instead of being SIGKILLed 10s later.
HEALTHCHECK uses Node's built-in fetch (Node 18+ global), no curl needed, directly calling /healthz. --start-period=15s gives the gateway bootstrap a buffer (loading plugins, connecting channels); a 3-minute probe interval is timely enough but not noisy. --retries=3 means 3 consecutive failures before marking unhealthy, avoiding false positives from jitter.
docker-compose.yml's health check is more aggressive: 30s interval, 5 retries, 20s start_period (compose healthcheck). Compose's command line overrides the default --bind lan, because under bridge networking loopback bind can't be accessed from the host — this is the pitfall explicitly warned in the Dockerfile comments. Compose also pins the container-side env var paths:
environment:
HOME: /home/node
OPENCLAW_HOME: /home/node
# Pin container-side state, workspace, and config paths so host values written to
# `.env` (used by Compose for the bind-mount source below) cannot leak
# into runtime code that resolves these env vars inside the container.
# Without this override, a macOS host path like /Users/<you>/.openclaw/...
# imported from .env caused first-reply `mkdir '/Users'` EACCES failures
# in Linux Docker (#77436).
OPENCLAW_STATE_DIR: /home/node/.openclaw
OPENCLAW_CONFIG_PATH: /home/node/.openclaw/openclaw.json
OPENCLAW_CONFIG_DIR: /home/node/.openclaw
OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspaceThis override fixes #77436 — the user wrote OPENCLAW_STATE_DIR=/Users/alex/.openclaw in .env, compose injected it into the container, and the container's Node tried mkdir '/Users' and EACCES'd. So compose force-overrides these four container-side paths to /home/node/.openclaw; host paths are only bind-mount sources.
fly.toml (fly.toml) deploys the same image to Fly.io:
app = "openclaw"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[env]
NODE_ENV = "production"
OPENCLAW_PREFER_PNPM = "1"
OPENCLAW_STATE_DIR = "/data"
NODE_OPTIONS = "--max-old-space-size=1536"
[processes]
app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = false # keep resident
auto_start_machines = true
min_machines_running = 1
processes = ["app"]
[[vm]]
size = "shared-cpu-2x"
memory = "2048mb"
[mounts]
source = "openclaw_data"
destination = "/data"Fly uses internal_port = 3000 for health probing (corresponding to compose's OPENCLAW_GATEWAY_PORT); force_https = true lets Fly's edge terminate TLS; auto_stop_machines = false + min_machines_running = 1 ensures WebSocket long-lived connections aren't killed by Fly's scale-to-zero — this is critical for agent system deployment: the gateway must be resident; if it drops, all client sessions are lost. NODE_OPTIONS = "--max-old-space-size=1536" with the 2GB vm memory leaves ~500MB for Bun/native addon/Playwright, avoiding OOM kill.
Boundaries and failures
- base image SHA pin:
OPENCLAW_NODE_BOOKWORM_IMAGE/OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE/OPENCLAW_BUN_IMAGEall pinned to SHA256 digests (digest pin:L12-L17). Dependabot updates these blessed digests — release builds consume reviewed snapshots rather than pulling latest on every build, ensuring reproducible builds. - matrix-sdk-crypto download retry (
matrix-sdk-crypto retry:L82-L97): the matrix-sdk-crypto-nodejs native addon CDN is unstable; might exit 0 but the.nodefile didn't download. Code retries 5 times +sleep $((attempt * 2))exponential backoff; final failure exits 1. Only runs this check whenOPENCLAW_EXTENSIONSincludesmatrix. - A2UI cross-arch stub (
a2ui stub:L113-L118):pnpm canvas:a2ui:bundlecan fail on QEMU cross-arch builds (amd64 on Apple Silicon); CI builds natively per-arch so it's unaffected. Here it falls back to a stub (non-fatal), letting local cross-builds not fail on UI bundle. OPENCLAW_PREFER_PNPM=1(OPENCLAW_PREFER_PNPM:L124): UI build uses pnpm rather than Bun; Bun has compat issues on ARM/Synology architectures.prune --prod --offline(runtime-assets prune:L140-L154): firstpnpm store addseeds the tarball, then--config.offline=trueprunes, avoiding going online during prune. After prune,node scripts/check-package-dist-imports.mjsvalidates import boundaries, preventing dev-only deps from appearing in the prod image.- non-root but
/home/node/.configpermission pitfall (config dir ownership:L309-L320):install -dwithout-o nodecreates/home/node/.configas root:root, then theopenclawsubdirectory inherits root ownership, and the container running asnodecan't write into it (#85968). The code firstinstall -d -m 0755 -o node -g node /home/node/.configthen creates subdirectories, and usesstat -cto verify permissions match exactly. - Docker CLI GPG single-key enforcement (
docker gpg single key:L283-L289): whenOPENCLAW_INSTALL_DOCKER_CLI=1, after downloading the Docker apt signing key,gpg --show-keys --with-colonscountspublines, requiring exactly 1 — multi-key files are rejected, preventing "apt trusts the first key in the multi-key file, but the actual trusted one is another". - bind-mount path leak (
compose env override): the macOS path in host.envcan't enter the container, so compose force-overridesOPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH/OPENCLAW_CONFIG_DIR/OPENCLAW_WORKSPACE_DIRto fixed paths under/home/node. The bind-mount source uses${HOME}/.openclaw; host-side can still specify. - Fly resident semantics:
auto_stop_machines = false+min_machines_running = 1is a hard constraint for agent gateway deployment — Fly defaults to scale-to-zero to save money, but WebSocket long-lived connections break, losing all IDE/client sessions. Fly deployments must explicitly disable auto-stop.shared-cpu-2x+ 2GB is the minimum reasonable config; below this memory Node OOMs. - Render auto-generated token:
render.yaml'sOPENCLAW_GATEWAY_TOKEN: generateValue: truelets Render platform auto-generate and inject — avoiding the user hard-coding the token in yaml.healthCheckPath: /healthuses the alias instead of/healthz— Render docs are more accustomed to this path. cap_drop: [NET_RAW, NET_ADMIN]+no-new-privileges: even if the container is compromised, it can't construct raw sockets (prevents port scanning/ARP spoofing) and can'tsetuidto escalate. This is the defense-in-depth baseline for containerized deployment; daemon mode can't achieve this level of isolation.
Summary
Containerized deployment swaps launchd/systemd/schtasks for Docker Engine/Fly machine/Render, but OpenClaw's responsibility division is unchanged: the gateway process still runs node openclaw.mjs gateway (see Gateway core), just the entrypoint wraps tini for signal + zombie handling, and the health check uses the built-in /healthz//readyz endpoints. Three deployment targets (Docker Compose local self-host / Fly.io cloud / Render.com cloud) share the same Dockerfile; the difference is only in orchestrator-specific volume mounts, resource specs, and token injection. Physical-machine or VM deployment doesn't need Docker and can go through Daemon: system service to install as a system service directly; the config file (openclaw.json) is the same under both forms, see openclaw.json.