Files
openclaw/docs/install/docker.md
Jesse Merhi 0e8faacd71 fix(scripts): build heap ignores its systemd memory budget and takes the full default (#123979)
* fix(scripts): size the tsdown heap from the build's own cgroup budget

The build heap probe only read the cgroup root (/sys/fs/cgroup/memory.max and
the v1 equivalent). Those files exist only when the process runs in a
namespaced container cgroup; under systemd the budget lives on the process's
own slice, and the v2 root carries no limit at all. So every systemd-managed
build found no limit, fell back to /proc/meminfo MemTotal, and took the full
12288 MB default heap regardless of its actual budget.

Observed on a 15.4 GiB host: openclaw-main-update.service ran tsdown with
NODE_OPTIONS=--max-old-space-size=12288 while its user@999.service slice was
bounded at 5 GiB, reaching 3.2 GB RSS and 6.25 GB peak before the host began
OOM-killing unrelated services.

Resolve the limit from /proc/self/cgroup and walk that chain instead, reading
memory.high alongside memory.max (memory.high throttles reclaim rather than
failing allocation, so a heap above it stalls the build instead of OOM-ing),
and take the tightest bound found. Root paths stay as the container fallback,
and an explicitly injected path list still disables detection.

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>

* fix(scripts): resolve the build heap budget from the v1 memory controller too

The slice walk only accepted the unified 0:: record, so a legacy or hybrid
systemd host fell back to the root probe and kept taking host memory. One
resolver now walks both hierarchies leaf-to-root, which makes the static root
list its own depth-0 case and removes it.

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>

* fix(scripts): read cgroup controller mounts instead of assuming their paths

v1 controllers can be co-mounted at the cgroup root, where memory.limit_in_bytes
sits under the slice with no per-controller directory, so the hardcoded
/sys/fs/cgroup/memory probe missed the budget and the build took the full
12288MB default. Mount points now come from mountinfo.

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>

* fix(scripts): translate cgroup records through the mount root

mountinfo field 4 is the subtree a cgroupfs mount exposes. Under a container
mount the /proc/self/cgroup record stays host-absolute, so walking it verbatim
probed paths below the visible mount and the build fell back to host memory.
Records now translate through the mount root before the walk.

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>

* fix(scripts): skip cgroup mounts that cannot represent this process

Falling back to the mount root for a record outside the mount's subtree sized
the build from an unrelated cgroup: an inherited namespace clamped the heap to
the 2048MB floor from a foreign 1GiB limit. Non-representable mounts are now
skipped, and the blind root probe only runs when no memory record exists.

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>

* fix(scripts): keep every cgroup mount view, not just the last one seen

One hierarchy can be visible through several mounts and only some expose a
subtree containing this process. Retaining only the last view dropped the
budget whenever a non-representable bind view came later, sending the build
back to host MemTotal.

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>

* fix(scripts): decode octal-escaped mountinfo paths before matching cgroups

ClawSweeper P2 on 7e64ad61f7: the cgroup resolver compared mountinfo's mount
root and mount point verbatim. The kernel escapes space, tab, newline, and
backslash in those two fields, so any cgroup mounted under such a path never
matched, the bounded slice was missed, and heap sizing silently fell back to
host memory.

Decode both fields before matching. The decoder lives in scripts/lib beside the
other shared script helpers rather than inline, so the scripts program has one
copy rather than a new ad hoc one.

Regression test fails pre-fix: a v2 mount at "/sys/fs/cgroup\040dir" with a
5 GiB memory.high yields --max-old-space-size=12288 (host fallback) before the
fix and 4352 after.

Follow-up, deliberately not bundled here: src/infra/sqlite-wal.ts,
src/commands/doctor-state-integrity.ts, and src/plugins/bundled-source-overlays.ts
each carry their own private copy of this same decoder. Consolidating all four
into @openclaw/normalization-core is the right end state, but it touches a
shared package plus three core modules and belongs in its own reviewable change.

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>

* fix(scripts): resolve cgroup-namespace-relative records to their mount

ClawSweeper P1 on d6fe49dd3f: inside a cgroup namespace /proc/self/cgroup
reports the namespace root ("0::/") while mountinfo field 4 stays the host
subtree the cgroupfs was mounted from ("/docker/<id>"). relativeCgroupPath then
found no prefix match and returned null; because a memory record had already
been seen, the root probe was skipped and the build fell back to host MemTotal.
A constrained container therefore missed its own budget entirely.

That namespace root is exactly what the mount exposes at its mount point, so it
resolves to "/" rather than failing closed.

Regression test fails pre-fix: a "0::/" record against a /docker/2f1a9c mount
root with a 5 GiB memory.max yields --max-old-space-size=12288 before the fix
and 4352 after.

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>

* fix(scripts): reject inherited cgroup mount views instead of guessing

ClawSweeper P1 on b4d200c5d2: the previous commit resolved a namespace-relative
record against any mount root, including the inherited views cgroup_namespaces(7)
documents, whose field-4 root reads "/..". Which cgroup such a view exposes is not
derivable from mountinfo, so probing it can size the build from an unrelated
cgroup's limit.

Reject non-canonical mount roots outright. An undecidable view now falls back to
host sizing, which is current main's behavior, rather than silently adopting the
wrong budget.

Regression test covers the "/.." inherited mount: it must yield host MemTotal
sizing, not the 5 GiB limit sitting behind that mount.

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>

* fix(scripts): fail closed on namespace-root records against non-root mounts

ClawSweeper P1 on 731d3bbc8e: a "0::/" record does not prove that a mount
rooted at some other subtree exposes this process's cgroup. Resolving that pair
could cap the build heap from an unrelated cgroup's limit.

Return no mapping for it. An undecidable pair now falls back to host sizing,
which is current main's behavior, so the failure mode is a missed optimisation
rather than a wrong budget. The "/.." inherited-mount rejection stays; this
covers the broader ambiguous mapping it did not.

The namespace-relative test is repointed accordingly: an unrelated mounted
subtree must yield host sizing, not that subtree's limit.

Net production change: none (4 lines swapped).

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>

* fix(build): cap tsdown heap to the real budget and refuse hosts that cannot build

The 2048MB floor was applied on top of a discovered cgroup limit, so a small
container was handed a heap larger than it could honour. Measured in real
cgroups, that does not OOM-kill, it thrashes: a 1500MiB container sat pinned at
its ceiling for 10 minutes with oom_kill at 0, never finished the second of
eleven invocations, and starved every other process on the host.

Cap to the discovered budget, then refuse up front when that budget cannot hold
the build. The threshold is the whole-build peak, not a single pass: a full
eleven-invocation build peaks at 4730MiB, so a 5GiB slice completes while 4GiB
and 2816MiB slices are both killed partway through the third invocation.

The refusal runs before any output is cleaned, so a host that cannot rebuild
does not also lose the build it has.

* fix(build): harden tsdown heap admission

* fix(build): guard the default tsdown plan

* fix(build): preserve runtime-only Docker builds

* fix(build): admit only declaration cache misses

* fix(build): scope heap admission to real budgets

* fix(build): guard direct unified declarations

* fix(build): guard the canonical tsdown config

* fix(build): satisfy cache planning lint

* fix(gateway): release empty orphan leases

* fix(build): cap cgroup budget by host memory

* fix(build): serialize the canonical tsdown config

* test(build): freeze host memory fixtures

* fix(build): honor cgroup v1 soft limits

* fix(build): respect cgroup v1 hierarchy mode

* fix(build): admit unified runtime plans

* fix(build): admit every unified runtime path

* fix(build): collect repeated tsdown filters

* fix(build): ignore cgroup v1 soft limits

* fix(build): use explicit heap override as opt-in

* refactor(build): simplify memory admission

* fix(build): harden constrained build recovery

* fix(ci): prebuild runtime before real CLI shards

* fix(build): honor runtime-only runner environment

* fix(ci): satisfy tooling shard lint
2026-08-24 14:18:48 +10:00

36 KiB

summary, read_when, title
summary read_when title
Optional Docker-based setup and onboarding for OpenClaw
You want a containerized gateway instead of local installs
You are validating the Docker flow
Docker

Docker is optional. Use it for an isolated, throwaway gateway environment or a host without local installs. If you already develop on your own machine, use the normal install flow instead.

The default Docker sandbox backend uses only the docker CLI. Set the backend to "podman" to select native Podman directly. Sandboxing is off by default and does not require the gateway itself to run in a container. SSH and OpenShell sandbox backends are also available; see Sandboxing.

Hosting multiple users? See Multi-tenant hosting for the one-cell-per-tenant model.

Prerequisites

  • Docker Desktop (or Docker Engine) + Docker Compose v2
  • At least 6 GB RAM for a local source image build; pre-built images avoid this build requirement
  • Enough disk for images and logs
  • On a VPS/public host, review Security hardening for network exposure, especially the Docker DOCKER-USER firewall chain

Containerized gateway

From the repo root:
```bash
./scripts/docker/setup.sh
```

This builds the gateway image locally as `openclaw:local`. To use a pre-built image instead:

```bash
export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest"
./scripts/docker/setup.sh
```

Pre-built images are published first to the [GitHub Container Registry](https://github.com/openclaw/openclaw/pkgs/container/openclaw). GHCR is the primary registry for release automation, pinned deployments, and provenance checks. The same release publishes a Docker Hub mirror at `openclaw/openclaw`:

```bash
export OPENCLAW_IMAGE="openclaw/openclaw:latest"
./scripts/docker/setup.sh
```

Use `ghcr.io/openclaw/openclaw` or `openclaw/openclaw` and avoid unofficial mirrors, which don't share OpenClaw's release timing or retention policy. Version-specific tags include releases such as `2026.2.26` and prereleases such as `2026.2.26-beta.1`. Stable releases move `latest` and `main`; trailing-month Gateway releases move only `extended-stable`. Variants include `slim`, `main-slim`, `extended-stable-slim`, `latest-browser`, `main-browser`, and `extended-stable-browser`. The default images bundle the `codex` and `diagnostics-otel` plugins. A `-browser` variant also ships with Chromium baked in, useful for the [sandboxed browser](/gateway/sandboxing#sandboxed-browser) tool without a first-run Playwright install.
On offline hosts, transfer and load the image first:
```bash
docker load -i openclaw-image.tar
export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest"
./scripts/docker/setup.sh --offline
```

`--offline` verifies `OPENCLAW_IMAGE` already exists locally, disables implicit Compose pulls/builds, then runs the normal flow: `.env` sync, permission fixes, onboarding, gateway config sync, Compose startup.

If `OPENCLAW_SANDBOX=1`, offline setup also checks the configured default and per-agent sandbox images on the daemon behind `OPENCLAW_DOCKER_SOCKET`, including the browser-contract label on Docker-backed browser images. If a required image is missing or stale, setup exits without changing sandbox config rather than reporting a broken success.
The setup script runs onboarding automatically:
- prompts for provider API keys
- generates a gateway token and writes it to `.env`
- creates the auth-profile secret key directory
- starts the gateway via Docker Compose

Pre-start onboarding and config writes run through `openclaw-gateway` directly (with `--no-deps --entrypoint node`), since `openclaw-cli` shares the gateway's network namespace and only works once the gateway container exists.
Open `http://127.0.0.1:18789/` and paste the token written to `.env` into Settings. If you switched the container to password auth, use that password instead.
Need the URL again?

```bash
docker compose run --rm openclaw-cli dashboard --no-open
```
```bash # WhatsApp (QR) docker compose run --rm openclaw-cli channels login
# Telegram
docker compose run --rm openclaw-cli channels add --channel telegram --token "<token>"

# Discord
docker compose run --rm openclaw-cli channels add --channel discord --token "<token>"
```

Docs: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord)

Headless bootstrap

For an unattended container host, put provider, Gateway, and channel credentials in the Compose .env file so both the one-shot bootstrap container and the long-running Gateway receive the same values:

OPENAI_API_KEY=<provider-key>
OPENCLAW_GATEWAY_TOKEN=<gateway-token>
TELEGRAM_BOT_TOKEN=<bot-token>

Run onboarding and channel provisioning without a pseudo-TTY, then start the Gateway:

docker compose run -T --rm --no-deps --entrypoint node openclaw-gateway \
  dist/index.js 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
docker compose run -T --rm --no-deps --entrypoint node openclaw-gateway \
  dist/index.js channels add --channel telegram --use-env
docker compose up -d openclaw-gateway

The channel command fails before changing config if a plugin-declared environment variable is missing. Keep TELEGRAM_BOT_TOKEN in .env after bootstrap: --use-env leaves credential lookup to the environment without copying the token into openclaw.json, and the running Gateway needs the same variable. When channel config changes after startup, the Gateway's config watcher hot-reloads the affected channel automatically.

See openclaw channels for credential-flag alternatives and other channel plugins.

Manual flow

BUILD_GIT_COMMIT="$(git rev-parse HEAD)"
BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker build \
  --build-arg "GIT_COMMIT=${BUILD_GIT_COMMIT}" \
  --build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}" \
  -t openclaw:local -f Dockerfile .
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
  dist/index.js onboard --mode local --no-install-daemon
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
  dist/index.js config set --batch-json '[{"path":"gateway.mode","value":"local"},{"path":"gateway.bind","value":"lan"},{"path":"gateway.controlUi.allowedOrigins","value":["http://localhost:18789","http://127.0.0.1:18789"]}]'
docker compose up -d openclaw-gateway

The Docker context excludes .git. Pass the source identity as build arguments as shown above so the image's About screen reports the checked-out commit and one build timestamp. scripts/docker/setup.sh resolves and passes both values automatically.

Run `docker compose` from the repo root. If you enabled `OPENCLAW_EXTRA_MOUNTS` or `OPENCLAW_HOME_VOLUME`, the setup script writes `docker-compose.extra.yml`; include it after any `docker-compose.override.yml` you maintain yourself, e.g. `-f docker-compose.yml -f docker-compose.override.yml -f docker-compose.extra.yml`.

Upgrading container images

When you replace the OpenClaw image but keep the same mounted state/config, the new gateway runs startup-safe upgrade migrations and plugin convergence before readiness. Routine image upgrades should not require a separate openclaw doctor --fix pass.

If startup cannot complete those repairs safely, the gateway exits instead of reporting healthy. With a restart policy, Docker, Podman, or Kubernetes may show the gateway container restarting. Keep the mounted state volume, then run the same image once with openclaw doctor --fix as the container command, using the same state/config mounts the gateway uses:

docker run --rm -v <openclaw-state>:/home/node/.openclaw <image> openclaw doctor --fix
podman run --rm -v <openclaw-state>:/home/node/.openclaw <image> openclaw doctor --fix

After doctor finishes, restart the gateway container with its default command. In Kubernetes, run the same command in a one-off Job or debug pod mounted to the same PVC, then restart the Deployment or StatefulSet.

After the container is running again, run the read-only deployment preflight against the same mounted state:

docker compose run --rm openclaw-cli doctor --json

Environment variables

Optional variables accepted by scripts/docker/setup.sh (and, for the gateway container, by docker-compose.yml directly):

Variable Purpose
OPENCLAW_IMAGE Use a remote image instead of building locally
OPENCLAW_IMAGE_APT_PACKAGES Install extra apt packages during build (space-separated). Legacy alias: OPENCLAW_DOCKER_APT_PACKAGES
OPENCLAW_IMAGE_PIP_PACKAGES Install extra Python packages during build (space-separated)
OPENCLAW_EXTENSIONS Compile/package supported selected plugins and install their runtime dependencies (comma- or space-separated ids)
OPENCLAW_DOCKER_BUILD_NODE_OPTIONS Override the local source-build Node options (default --max-old-space-size=8192)
OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB Override the local source-build tsdown heap in MB
OPENCLAW_DOCKER_BUILD_SKIP_DTS Skip declaration output during runtime-only local image builds (default 1)
OPENCLAW_INSTALL_BROWSER Bake Chromium + Xvfb into the image at build time
OPENCLAW_EXTRA_MOUNTS Extra host bind mounts (comma-separated source:target[:opts])
OPENCLAW_HOME_VOLUME Persist /home/node in a named Docker volume
OPENCLAW_TZ Set the gateway and CLI container timezone to an IANA name (default UTC)
OPENCLAW_SANDBOX Opt in to sandbox bootstrap (1, true, yes, on)
OPENCLAW_SKIP_ONBOARDING Skip the interactive onboarding step (1, true, yes, on)
OPENCLAW_DOCKER_SOCKET Override the Docker socket path
OPENCLAW_DISABLE_BONJOUR Force Bonjour/mDNS advertising on (0) or off (1); see Bonjour / mDNS
OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS Disable bundled plugin source bind-mount overlays
OTEL_EXPORTER_OTLP_ENDPOINT Shared OTLP/HTTP collector endpoint for OpenTelemetry export
OTEL_EXPORTER_OTLP_*_ENDPOINT Signal-specific OTLP endpoints for traces, metrics, or logs
OTEL_EXPORTER_OTLP_PROTOCOL Shared OTLP protocol fallback. Only http/protobuf is supported today
OTEL_EXPORTER_OTLP_*_PROTOCOL Signal-specific protocol fallback for traces, metrics, or logs; wins over the shared fallback
OTEL_SERVICE_NAME Service name used for OpenTelemetry resources
OTEL_SEMCONV_STABILITY_OPT_IN Opt in to latest experimental GenAI semantic attributes
OPENCLAW_OTEL_PRELOADED Skip starting a second OpenTelemetry SDK when one is preloaded

The official image ships no Homebrew. During onboarding, OpenClaw hides brew-only skill dependency installers in a Linux container without brew; provide those dependencies through a custom image or install manually. Use OPENCLAW_IMAGE_APT_PACKAGES for Debian-packaged dependencies and OPENCLAW_IMAGE_PIP_PACKAGES for Python dependencies (runs python3 -m pip install --break-system-packages at build time, so pin versions and use only indexes you trust).

If Docker reports ResourceExhausted, cannot allocate memory, or aborts during tsdown, increase the Docker builder memory limit or retry with smaller explicit heaps:

OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=4096

The explicit tsdown heap override is also the supported opt-in for attempting a build below the automatically detected safe minimum. That attempt may stall or fail.

Source-built images with selected plugins

OPENCLAW_EXTENSIONS selects plugin manifest ids from the source checkout; existing source-directory names are also accepted when they differ. The Docker build resolves the selection to source directories once, installs production dependencies, and includes the selected plugin runtime in the image. Source checkouts also compile first-party plugins published separately with openclaw.build.bundledDist: false; that marker still preserves the plugin's external npm or ClawHub ownership and does not change either artifact contract. Unknown, invalid, or ambiguous ids fail the image build. Known dependency/source-only ids keep their existing source and dependency staging without gaining a compiled root dist entry. A selected plugin with unified build entries must compile successfully; unselected external plugin source and runtime output are pruned.

For example, these commands build separate, multi-architecture standalone FakeCo gateway images for ClickClack, Slack, and Microsoft Teams. ClawRouter is already part of the root OpenClaw runtime, so the ClickClack image selects only clickclack. The explicit empty browser argument keeps the default image free of Chromium:

SOURCE_SHA="$(git rev-parse HEAD)"
BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
REGISTRY="registry.example.com/fakeco"

build_gateway_image() {
  gateway="$1"
  selected_plugin="$2"
  docker buildx build \
    --platform linux/amd64,linux/arm64 \
    --build-arg "GIT_COMMIT=${SOURCE_SHA}" \
    --build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}" \
    --build-arg "OPENCLAW_EXTENSIONS=${selected_plugin}" \
    --build-arg OPENCLAW_INSTALL_BROWSER= \
    --provenance=mode=max \
    --sbom=true \
    --tag "${REGISTRY}/openclaw-${gateway}:${SOURCE_SHA}" \
    --push \
    .
}

build_gateway_image clickclack clickclack
build_gateway_image slack slack
build_gateway_image teams msteams

Use --platform linux/arm64 --load or --platform linux/amd64 --load for a single native local build. Multi-platform output and attached SBOM/provenance require a registry or another Buildx output that preserves attestations. After pushing, inspect the manifest and deploy the immutable digest rather than the mutable source-SHA tag:

docker buildx imagetools inspect \
  "${REGISTRY}/openclaw-clickclack:${SOURCE_SHA}"
# Deploy: registry.example.com/fakeco/openclaw-clickclack@sha256:<manifest-digest>

These images are for standalone OCI-based gateways and generic Docker users. Crabhelm-managed gateways do not consume them: that delivery path builds a separate x86_64 appliance archive containing an OpenClaw npm tarball and pins the Node, archive, and manifest digests. Build that appliance independently from the same landed OpenClaw source.

To test bundled plugin source against a packaged image, mount one plugin source directory over its packaged source path, e.g. OPENCLAW_EXTRA_MOUNTS=/path/to/fork/extensions/synology-chat:/app/extensions/synology-chat:ro. That overrides the matching compiled /app/dist/extensions/synology-chat bundle for the same plugin id.

Observability

OpenTelemetry export is outbound from the Gateway container to your OTLP collector; it needs no published Docker port. To include the bundled exporter in a locally built image:

export OPENCLAW_EXTENSIONS="diagnostics-otel"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector:4318"
export OTEL_SERVICE_NAME="openclaw-gateway"
./scripts/docker/setup.sh

Official prebuilt images already bundle diagnostics-otel; install clawhub:@openclaw/diagnostics-otel yourself only if you removed it. To enable export, allow and enable the diagnostics-otel plugin in config, then set diagnostics.otel.enabled=true (see the full example in OpenTelemetry export). Collector auth headers go through diagnostics.otel.headers, not Docker environment variables.

Prometheus metrics reuse the already-published Gateway port. Install clawhub:@openclaw/diagnostics-prometheus, enable the diagnostics-prometheus plugin, then scrape:

http://<gateway-host>:18789/api/diagnostics/prometheus

The route is protected by Gateway authentication; don't expose a separate public /metrics port or unauthenticated reverse-proxy path. See Prometheus metrics.

Health checks

Container probe endpoints (no auth required):

curl -fsS http://127.0.0.1:18789/healthz   # liveness
curl -fsS http://127.0.0.1:18789/startupz  # startup and traffic admission
curl -fsS http://127.0.0.1:18789/readyz    # deep, channel-aware readiness

The image's built-in HEALTHCHECK pings /healthz; repeated failures mark the container unhealthy so orchestrators can restart or replace it. Use /startupz for an orchestrator startup or readiness probe so a failed channel account does not remove the otherwise healthy Gateway and Control UI from service. Use /readyz for monitoring that intentionally treats hard channel failures as not ready. See Health checks for response details.

Authenticated deep health snapshot:

docker compose exec openclaw-gateway sh -lc 'node dist/index.js gateway health --token "$OPENCLAW_GATEWAY_TOKEN"'

LAN vs loopback

scripts/docker/setup.sh defaults OPENCLAW_GATEWAY_BIND=lan so http://127.0.0.1:18789 on the host works with Docker port publishing.

  • lan (default): host browser and host CLI can reach the published gateway port.
  • loopback: only processes inside the container network namespace can reach the gateway directly.
Use bind mode values in `gateway.bind` (`lan` / `loopback` / `custom` / `tailnet` / `auto`), not host aliases like `0.0.0.0` or `127.0.0.1`.

Host local providers

Inside the container, 127.0.0.1 is the container itself, not the host. Use host.docker.internal for providers running on the host:

Provider Host default URL Docker setup URL
LM Studio http://127.0.0.1:1234 http://host.docker.internal:1234
Ollama http://127.0.0.1:11434 http://host.docker.internal:11434

The bundled setup uses those URLs as LM Studio/Ollama onboarding defaults, and docker-compose.yml maps host.docker.internal to the host gateway on Linux Docker Engine (Docker Desktop provides the same alias on macOS/Windows). Host services must listen on an address Docker can reach:

lms server start --port 1234 --bind 0.0.0.0
OLLAMA_HOST=0.0.0.0:11434 ollama serve

Using your own Compose file or docker run? Add the same mapping yourself, e.g. --add-host=host.docker.internal:host-gateway.

Claude CLI backend in Docker

The official image does not pre-install Claude Code. Install and log in inside the container's node user, then persist that container home so image upgrades don't erase the binary or auth state.

For a new install, enable a persistent /home/node volume before running setup:

export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest"
export OPENCLAW_HOME_VOLUME="openclaw_home"
./scripts/docker/setup.sh

For an existing install, stop the stack and reload the current .env values first — the setup script always rewrites .env from the current shell and defaults, it doesn't read the file on its own:

set -a
. ./.env
set +a
export OPENCLAW_HOME_VOLUME="${OPENCLAW_HOME_VOLUME:-openclaw_home}"
./scripts/docker/setup.sh

If .env contains values your shell can't source, re-export what you rely on manually first (OPENCLAW_IMAGE, ports, bind mode, custom paths, OPENCLAW_EXTRA_MOUNTS, sandbox, skip-onboarding). The generated overlay mounts the home volume for both openclaw-gateway and openclaw-cli; run the remaining commands with that overlay (and docker-compose.override.yml first, if you use one):

docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
  --entrypoint sh openclaw-cli -lc \
  'curl -fsSL https://claude.ai/install.sh | bash'

The native installer writes claude to /home/node/.local/bin/claude. The OpenClaw image includes /home/node/.local/bin on PATH, so the bundled Anthropic plugin resolves it without an adapter config override.

Log in and verify from the same persisted home:

docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
  --entrypoint /home/node/.local/bin/claude openclaw-cli auth login
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
  --entrypoint /home/node/.local/bin/claude openclaw-cli auth status --text
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
  openclaw-cli models auth login \
  --provider anthropic --method cli --set-default
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
  openclaw-cli models list --provider anthropic

Then use the bundled claude-cli backend:

docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
  openclaw-cli agent \
  --agent main \
  --model claude-cli/claude-sonnet-4-6 \
  --message "Say hello from Docker Claude CLI"

OPENCLAW_HOME_VOLUME persists the native install under /home/node/.local/bin and /home/node/.local/share/claude, plus Claude Code settings/auth under /home/node/.claude and /home/node/.claude.json. Persisting only /home/node/.openclaw is not enough; if you use OPENCLAW_EXTRA_MOUNTS instead of a home volume, mount all of those Claude paths into both services.

For shared production automation or predictable Anthropic billing, prefer the Anthropic API-key path. Claude CLI reuse follows Claude Code's installed version, account login, billing, and update behavior.

Bonjour / mDNS

Docker bridge networking usually doesn't forward Bonjour/mDNS multicast (224.0.0.251:5353) reliably. When OPENCLAW_DISABLE_BONJOUR is unset, the bundled Bonjour plugin auto-disables LAN advertising once it detects it's running in a container, so it won't crash-loop retrying multicast the bridge drops. Set OPENCLAW_DISABLE_BONJOUR=1 to force it off regardless of detection, or 0 to force it on (only on host networking, macvlan, or another network where mDNS multicast is known to work).

Use the published Gateway URL, Tailscale, or wide-area DNS-SD for Docker hosts otherwise. See Bonjour discovery for gotchas and troubleshooting.

Storage and persistence

Docker Compose bind-mounts OPENCLAW_CONFIG_DIR to /home/node/.openclaw, OPENCLAW_WORKSPACE_DIR to /home/node/.openclaw/workspace, and OPENCLAW_AUTH_PROFILE_SECRET_DIR to /home/node/.config/openclaw, so those paths survive container replacement. When a variable is unset, docker-compose.yml falls back under ${HOME}, or /tmp if HOME itself is missing, so docker compose up never emits an empty-source volume spec on bare environments.

That mounted config directory holds:

  • openclaw.json for behavior config
  • agents/<agentId>/agent/auth-profiles.json for stored provider OAuth/API-key auth
  • .env for env-backed runtime secrets such as OPENCLAW_GATEWAY_TOKEN

The auth-profile secret directory stores the local encryption key for OAuth-backed auth profile token material. Keep it with your Docker host state, but separate from OPENCLAW_CONFIG_DIR.

Installed downloadable plugins store package state under the mounted OpenClaw home, so install records and package roots survive container replacement; gateway startup does not regenerate bundled-plugin dependency trees.

For full VM persistence details, see Docker VM Runtime - What persists where.

Disk growth hotspots: media/, per-agent SQLite databases, legacy session JSONL transcripts, the shared SQLite state database, installed plugin package roots, and rolling file logs under /tmp/openclaw/.

Shell helpers (optional)

For shorter day-to-day commands, install ClawDock:

mkdir -p ~/.clawdock && curl -sL https://raw.githubusercontent.com/openclaw/openclaw/main/scripts/clawdock/clawdock-helpers.sh -o ~/.clawdock/clawdock-helpers.sh
echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrc && source ~/.zshrc

If you installed from the older scripts/shell-helpers/clawdock-helpers.sh path, rerun the command above so your local helper tracks the current location. Then use clawdock-start, clawdock-stop, clawdock-dashboard, etc. (run clawdock-help for the full list).

```bash export OPENCLAW_SANDBOX=1 ./scripts/docker/setup.sh ```
Custom socket path (e.g. rootless Docker):

```bash
export OPENCLAW_SANDBOX=1
export OPENCLAW_DOCKER_SOCKET=/run/user/1000/docker.sock
./scripts/docker/setup.sh
```

The script mounts `docker.sock` only after sandbox prerequisites pass. If sandbox setup can't complete, it resets `agents.defaults.sandbox.mode` to `off`. Codex code mode is disabled for turns where the OpenClaw sandbox is active (see [Sandboxing § Docker backend](/gateway/sandboxing#docker-backend)); never mount the host Docker socket into agent sandbox containers.
Disable Compose pseudo-TTY allocation with `-T`:
```bash
docker compose run -T --rm openclaw-cli gateway probe
docker compose run -T --rm openclaw-cli devices list --json
```
`openclaw-cli` uses `network_mode: "service:openclaw-gateway"` so CLI commands can reach the gateway over `127.0.0.1`. Treat this as a shared trust boundary. The compose config drops `NET_RAW`/`NET_ADMIN` and enables `no-new-privileges` on both `openclaw-gateway` and `openclaw-cli`. Some Docker Desktop setups fail DNS lookups from the shared-network `openclaw-cli` sidecar after `NET_RAW` is dropped, showing up as `EAI_AGAIN` during npm-backed commands like `openclaw plugins install`. Keep the default hardened compose file for normal operation. The override below restores default capabilities for the `openclaw-cli` container only — use it for the one-off command that needs registry access, not as your default invocation:
```bash
printf '%s\n' \
  'services:' \
  '  openclaw-cli:' \
  '    cap_drop: !reset []' \
  > docker-compose.cli-no-dropped-caps.local.yml

docker compose -f docker-compose.yml -f docker-compose.cli-no-dropped-caps.local.yml run --rm openclaw-cli plugins install <package>
```

If you already created a long-running `openclaw-cli` container, recreate it with the same override — `docker compose exec`/`docker exec` can't change Linux capabilities on an already-created container.
The image runs as `node` (uid 1000). If you see permission errors on `/home/node/.openclaw`, make sure your host bind mounts are owned by uid 1000:
```bash
sudo chown -R 1000:1000 /path/to/openclaw-config /path/to/openclaw-workspace
```

The same mismatch can show up as `blocked plugin candidate: suspicious ownership (... uid=1000, expected uid=0 or root)` followed by `plugin present but blocked` — the process uid and the mounted plugin directory owner disagree. Prefer running as the default uid 1000 and fixing the bind mount ownership. Only chown `/path/to/openclaw-config/npm` to `root:root` if you intentionally run OpenClaw as root long term.
Use the repo-root `Dockerfile` instead of replacing it with a shortened single-stage example. Its `workspace-deps` stage extracts the package manifests required by `pnpm-workspace.yaml`, then the build stage copies those manifests before `pnpm install --frozen-lockfile`. This keeps the dependency layer cacheable without omitting `packages/*`, selected `extensions/*`, or other required workspace metadata.
The same Dockerfile preserves the production runtime contract: digest-pinned
Node and Bun bases, non-root uid 1000, `tini`, the built-in health check, and
the `/usr/local/bin/openclaw` symlink. Dependabot refreshes the reviewed base
digests; do not replace them with floating `FROM node:24-bookworm` tags.
The default image is security-first and runs as non-root `node`. For a more full-featured container:
1. **Persist `/home/node`**: `export OPENCLAW_HOME_VOLUME="openclaw_home"`
2. **Bake system deps**: `export OPENCLAW_IMAGE_APT_PACKAGES="git curl jq"`
3. **Bake Python deps**: `export OPENCLAW_IMAGE_PIP_PACKAGES="requests==2.32.5 humanize==4.14.0"`
4. **Bake Playwright Chromium**: `export OPENCLAW_INSTALL_BROWSER=1`, or use the official `-browser` image tag
5. **Persist browser downloads and caches**: use `OPENCLAW_HOME_VOLUME` or `OPENCLAW_EXTRA_MOUNTS`. OpenClaw auto-detects the image's Playwright-managed Chromium on Linux.
If you pick OpenAI Codex OAuth in the wizard, it opens a browser URL. In Docker or headless setups, copy the full redirect URL you land on and paste it back into the wizard to finish auth. The runtime image uses `node:24-bookworm-slim` and runs `tini` as PID 1 so zombie processes are reaped and signals handled correctly in long-running containers. It publishes OCI base-image annotations including `org.opencontainers.image.base.name` and `org.opencontainers.image.source`. Dependabot refreshes the pinned Node base digest, and each build applies current Debian point-release updates. See [OCI image annotations](https://github.com/opencontainers/image-spec/blob/main/annotations.md).

Image contents and security scanning

Runtime images contain production Node.js dependencies only. Release builds pin the base image by digest and apply current Debian security updates with apt-get dist-upgrade; the -browser variant installs the Chromium version pinned by its Playwright release.

Scanner totals can include Debian findings that the distribution marks wont-fix. To rebuild locally against current base and package metadata, run docker build --pull -t openclaw:local ..

Weekly image refreshes

The latest*, main*, and extended-stable* moving tags are rebuilt weekly from the same tagged release source so they pick up current OS security updates between OpenClaw releases. Stable and extended-stable refreshes remain separate, and beta images are not rebuilt on this schedule.

Each refresh also publishes a dated tag such as 2026.8.1-r20260820 (plus -slim and -browser variants). Plain version tags and dated -rYYYYMMDD tags are immutable; pin either form when you do not want a deployment to follow a moving tag.

Running on a VPS?

See Hetzner (Docker VPS) and Docker VM Runtime for shared VM deployment steps including binary baking, persistence, and updates.

Agent sandbox

When agents.defaults.sandbox is enabled with the Docker backend, the gateway runs agent tool execution (shell, file read/write, etc.) inside isolated Docker containers while the gateway itself stays on the host — a hard wall around untrusted or multi-tenant agent sessions without containerizing the whole gateway.

Sandbox scope can be per-agent (default), per-session, or shared; each scope gets its own workspace mounted at /workspace. You can also configure allow/deny tool policies, network isolation, resource limits, and browser containers.

For full configuration, images, security notes, and multi-agent profiles:

Quick enable

{
  agents: {
    defaults: {
      sandbox: {
        mode: "non-main", // off | non-main | all
        scope: "agent", // session | agent | shared
      },
    },
  },
}

Build the default sandbox image (from a source checkout):

scripts/sandbox-setup.sh

For npm installs without a source checkout, see Sandboxing § Images and setup for inline docker build commands.

Troubleshooting

Build the sandbox image with [`scripts/sandbox-setup.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/sandbox-setup.sh) (source checkout) or the inline `docker build` command from [Sandboxing § Images and setup](/gateway/sandboxing#images-and-setup) (npm install), or set `agents.defaults.sandbox.docker.image` to your custom image. Containers are auto-created per session on demand. Set `docker.user` to a UID:GID that matches your mounted workspace ownership, or chown the workspace folder. OpenClaw runs commands with `sh -lc` (login shell), which sources `/etc/profile` and may reset PATH. Set `docker.env.PATH` to prepend your custom tool paths, or add a script under `/etc/profile.d/` in your Dockerfile. A local source image build needs at least 6 GB RAM. Use a larger machine class or a pre-built image and retry. Fetch a fresh dashboard link and approve the browser device:
```bash
docker compose run --rm openclaw-cli dashboard --no-open
docker compose run --rm openclaw-cli devices list
docker compose run --rm openclaw-cli devices approve <requestId>
```

More detail: [Dashboard](/web/dashboard), [Devices](/cli/devices).
Reset gateway mode and bind:
```bash
docker compose run --rm openclaw-cli config set --batch-json '[{"path":"gateway.mode","value":"local"},{"path":"gateway.bind","value":"lan"}]'
docker compose run --rm openclaw-cli devices list --url ws://127.0.0.1:18789
```