Skip to content

Docker and Fly deployment

源码版本v2026.6.11

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?

  1. Minimal image: the full node:24-bookworm image is 1GB+; runtime only needs node:24-bookworm-slim + a few system packages like ca-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).
  2. 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=0o077 protects file permissions in daemon mode; the container achieves the same by pre-creating 0700 directories (install -d permissions:L312-L320).
  3. Built-in health check: HEALTHCHECK (HEALTHCHECK:L341-L342) directly calls the /healthz endpoint, no external probe script — the orchestrator (Docker Compose/Fly/Render) judges container liveness from this. /healthz is liveness; /readyz is readiness; aliases /health and /ready for habit compat.
  4. Cross-orchestrator: fly.toml, render.yaml, docker-compose.yml cover 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-L25OPENCLAW_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-L154pnpm prune --prod --offline, removes dev dependencies, d.ts, .map, node_modules/openclaw (self-reference).
  • Stage 3: runtime:L156-L189 — base bookworm-slim, installs ca-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-L305ln -sf /app/openclaw.mjs /usr/local/bin/openclaw, avoiding non-root npm global writes.
  • pre-create directory permissions:L312-L320install -d -m 0700 -o node -g node, then stat -c verify, preventing root-owned directory polluting a named volume on first mount.
  • ENV + USER + HEALTHCHECK + CMD:L322-L344NODE_ENV=production, USER node, HEALTHCHECK 3min interval + tini entrypoint + CMD ["node","openclaw.mjs","gateway"].
  • fly.tomlapp = "openclaw", primary_region = "iad", internal_port = 3000, auto_stop_machines = false (keep resident), min_machines_running = 1, shared-cpu-2x + 2GB, openclaw_data volume mounted to /data.
  • render.yamlruntime: docker, plan: starter, healthCheckPath: /health, OPENCLAW_GATEWAY_TOKEN: generateValue: true (auto-generated), 1GB disk mount /data.
  • docker-compose.ymlopenclaw-gateway + openclaw-cli two services; CLI uses network_mode: service:openclaw-gateway to share network namespace; mounts ~/.openclaw and secrets directory.

Data flow

The final steps of container startup (Dockerfile tail:L322-L344) are critical:

dockerfile
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:

yaml
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/workspace

This 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:

toml
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_IMAGE all 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 .node file didn't download. Code retries 5 times + sleep $((attempt * 2)) exponential backoff; final failure exits 1. Only runs this check when OPENCLAW_EXTENSIONS includes matrix.
  • A2UI cross-arch stub (a2ui stub:L113-L118): pnpm canvas:a2ui:bundle can 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): first pnpm store add seeds the tarball, then --config.offline=true prunes, avoiding going online during prune. After prune, node scripts/check-package-dist-imports.mjs validates import boundaries, preventing dev-only deps from appearing in the prod image.
  • non-root but /home/node/.config permission pitfall (config dir ownership:L309-L320): install -d without -o node creates /home/node/.config as root:root, then the openclaw subdirectory inherits root ownership, and the container running as node can't write into it (#85968). The code first install -d -m 0755 -o node -g node /home/node/.config then creates subdirectories, and uses stat -c to verify permissions match exactly.
  • Docker CLI GPG single-key enforcement (docker gpg single key:L283-L289): when OPENCLAW_INSTALL_DOCKER_CLI=1, after downloading the Docker apt signing key, gpg --show-keys --with-colons counts pub lines, 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 .env can't enter the container, so compose force-overrides OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH/OPENCLAW_CONFIG_DIR/OPENCLAW_WORKSPACE_DIR to 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 = 1 is 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's OPENCLAW_GATEWAY_TOKEN: generateValue: true lets Render platform auto-generate and inject — avoiding the user hard-coding the token in yaml. healthCheckPath: /health uses 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't setuid to 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.