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 mutually untrusted users? Run a separate Gateway for each trust boundary, ideally under separate OS users or hosts. See Security trust model.

Prerequisites

Containerized Gateway

Build the image

From the repo root:

```bash
./scripts/docker/setup.sh
```

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

```bash
export APUS_IMAGE="ghcr.io/apus/apus:latest"
./scripts/docker/setup.sh
```

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

```bash
export APUS_IMAGE="apus/apus:latest"
./scripts/docker/setup.sh
```

Use `ghcr.io/apus/apus` or `apus/apus` and avoid unofficial mirrors, which don't share Apus's release timing or retention policy. Version-specific tags include releases such as `2026.9.3` and prereleases such as `2026.9.1-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 for the [Gateway-controlled browser](/install/docker#using-the-control-ui-browser). The agent sandbox browser uses a separate image.

Airgapped rerun

On offline hosts, transfer and load the image first:

```bash
docker load -i apus-image.tar
export APUS_IMAGE="ghcr.io/apus/apus:latest"
./scripts/docker/setup.sh --offline
```

`--offline` verifies `APUS_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 `APUS_SANDBOX=1`, offline setup also checks the configured default and per-agent sandbox images on the daemon behind `APUS_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.

Complete onboarding

The setup script runs onboarding automatically:

- prompts for provider API keys
- generates a Gateway token and writes it to `.env`
- creates the legacy auth-profile secret key directory
- starts the Gateway via Docker Compose

Pre-start onboarding and config writes run through `apus-gateway` directly (with `--no-deps --entrypoint node`), since `apus-cli` shares the Gateway's network namespace and only works once the Gateway container exists.

Open the Control UI

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 apus-cli dashboard --no-open
```

With a custom `APUS_GATEWAY_PORT`, replace port `18789` in the printed URL with your host port before opening it in the browser; keep the rest of the URL intact. Dashboard commands inside either container use the internal listener port.

Configure channels (optional)

```bash
# WhatsApp (QR)
docker compose run --rm apus-cli channels login

# Telegram
docker compose run --rm apus-cli channels add --channel telegram --token "<token>"

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

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

Using the Control UI browser

The Control UI Browser panel displays a browser controlled by the Gateway. It is separate from the browser on your laptop or phone that opens the dashboard. For a local managed browser with a Docker Gateway, Chromium must be available inside the Gateway container.

For a new installation, use the official browser-equipped image with the normal Compose setup; no custom Dockerfile is needed:

export APUS_IMAGE="ghcr.io/apus/apus:latest-browser"
./scripts/docker/setup.sh

For a pinned deployment, choose a release's -browser tag instead of the moving latest-browser tag. For an existing Compose installation, change APUS_IMAGE in its .env to the browser variant, then pull and recreate the Gateway using the same Compose files and overlays as your current deployment:

docker compose pull apus-gateway apus-cli
docker compose up -d apus-gateway

Keep your existing volumes, ports, and other settings. Do not rerun setup with an empty shell environment to change only the image: setup rewrites .env from the current shell and defaults.

An existing home volume or bind mount covering /home/node/.cache/ms-playwright can hide the image's bundled Chromium. If browser discovery still fails after the image switch, check those mounts. Preserve the data, then either provision Chromium in the mounted home or adjust the mounts to leave the image's browser cache visible; recreating the container does not refresh a populated home volume.

For a new installation from a local source build, bake Chromium into the image:

APUS_IMAGE=apus:local APUS_INSTALL_BROWSER=1 ./scripts/docker/setup.sh

This build-time option installs Chromium and Xvfb; setting it on an already-built container does not install a browser.

The image supplies Chromium, not a replacement for your browser configuration:

This is not the separate sandboxed browser container used by sandboxed agent sessions. Selecting a Gateway -browser image does not build or configure that sandbox image.

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>
APUS_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 apus-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 APUS_GATEWAY_TOKEN \
  --skip-channels \
  --no-install-daemon
docker compose run -T --rm --no-deps --entrypoint node apus-gateway \
  dist/index.js channels add --channel telegram --use-env
docker compose up -d apus-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 apus.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 apus 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 "APUS_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}" \
  -t apus:local -f Dockerfile .
docker compose run --rm --no-deps --entrypoint node apus-gateway \
  dist/index.js onboard --mode local --no-install-daemon
docker compose run --rm --no-deps --entrypoint node apus-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 apus-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 APUS_EXTRA_MOUNTS or APUS_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 Apus image but keep the same mounted state/config, the image entrypoint runs apus doctor --fix --non-interactive under exclusive maintenance ownership before starting the Gateway. This covers the default image command and Compose's foreground Gateway command, including its selected profile. Routine image upgrades do not require a separate Doctor pass.

Activation retains a full pre-repair copy of local state under <state-dir>.update-captures/ the first time Doctor captures in a container. Later starts of the same container (docker stop/docker start, restart policies, host reboots) still run the Doctor repair pass but skip that repeated full copy, keep it, and still retire other Doctor copies older than 30 days; repair-specific backups still apply. If a capture fails, the next start tries again. A new image, a recreated container, or a container whose /tmp does not persist across restarts captures again.

On older Linux hosts such as Synology DSM, an unavailable openat2 syscall can make older images report that another Gateway owns even an empty state volume. Current images use the guarded filesystem fallback; keep native filesystem checks enabled. Doctor reports the underlying lock failure and recovery action instead of treating every acquisition error as an active Gateway. Permission errors require a writable state mount for the container user. If the filesystem cannot provide exclusive file creation for state ownership, stop Apus, back up its state, and move the state volume to a local filesystem that supports it. Ordinary database transactions use SQLite locking on that volume. Do not delete state or lock files to bypass ownership.

Other CLI commands and help pass through unchanged. If you replace the image's entrypoint, run Doctor against the same mounted state/config before launching the Gateway; a custom entrypoint bypasses this activation step.

This includes agent database schema upgrades, shared-state audit migrations, and legacy workspace setup imports. Before advancing database schemas, Doctor saves verified SQLite copies beside the originals as <database>.pre-startup-migration-<id>.bak. The shared database and affected agent databases use the same backup ID. Config backups and retired workspace-file archives follow the normal Doctor repair rules. Keep these files with your pre-upgrade backup; a rollback must restore the matching state as well as the old image. See rollback.

Retries with the same candidate build, database files, and target schemas verify and reuse the first completed backup group for the full database inventory, including after a partially completed migration. A temporary .bak.capturing file records an unfinished capture and is durably removed before migration starts. An interrupted capture is recaptured; a missing or damaged member of a completed group stops migration and preserves the surviving snapshots for recovery. Repeated failed attempts therefore do not create another full backup group each time. Older backup groups remain available for operator-managed rollback. Doctor records these originals for apus update cleanup --dry-run. They stay protected until Doctor verifies that the migration completed and a later update finishes successfully. Cleanup can then retire the recorded group after your confirmation. Older unrecorded .bak files are listed as protected. Keep these files with your pre-upgrade backups while you still need the matching rollback.

On FUSE filesystems such as Unraid's shfs, a missing native no-replace rename does not require an operator step in native auto mode when same-directory hardlinks are supported. fs-safe preserves the source inode and bytes through exclusive hardlink publication and unlink. Native require mode still refuses the unsupported primitive. Doctor recovers interrupted source/claim pairs without replacing another file. Its separate missing-addon/native-off compatibility path syncs the source and directory before removing the old name; native moves do not promise per-move crash durability. Archive hardening and durable recovery checkpoints remain owned by Doctor. SQLite backup verification rechecks snapshot bytes when FUSE modification or change timestamps drift, while still rejecting changed contents or file identities.

Readiness remains false while the default or system agent database is refused, and the readiness response includes the admission reason. A refused optional agent remains isolated while healthy agents can serve requests.

Missing or drifted canonical SQLite indexes are rebuilt by the schema migration owner before session startup completes. Repair warnings identify the agent, database path, rebuilt indexes, and elapsed time. Current-schema shape refusal reports list all affected databases in stable path order. Missing required tables, incompatible columns, and other changes that cannot be reconstructed safely still require Doctor; startup does not recreate a missing data table as an empty one.

On Linux hosts without file creation timestamps, normal SQLite writes and permission repairs can change the reported file birth time. These changes do not invalidate the open database; actual file replacement still stops admission.

Startup exits with code 78 when required state cannot be migrated safely: for example, source identities conflict, data is unreadable, another writer owns the state, or the filesystem provides no safe, durable way to publish a claim. The retained source, claim, and backups are recovery inputs; do not delete them to silence the error. If the filesystem lacks the required primitives, stop the Gateway and expose the same data through its native backing filesystem before retrying (for example, an Unraid pool path instead of the shfs share).

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 apus doctor --fix as the container command, using the same state/config mounts the Gateway uses:

docker run --rm -v <apus-state>:/home/node/.apus <image> apus doctor --fix
podman run --rm -v <apus-state>:/home/node/.apus <image> apus 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 apus-cli doctor --json

Source-built images with selected plugins

APUS_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, links each selected plugin's own runtime dependencies under its packaged root in /app/dist/extensions/<id>, and includes the selected plugin runtime in the image. Source checkouts also compile first-party plugins published separately with apus.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. This includes WhatsApp: APUS_EXTENSIONS=whatsapp compiles and packages its runtime. Ordinary source builds generate its runtime through the separate external-plugin build path; root npm artifacts continue to exclude it. Selected plugins must compile successfully; unselected external plugin source and runtime output are pruned.

The install layer stages every workspace's package.json so frozen-lockfile validation can check the complete workspace. The build stage installs workspace dependencies needed to compile plugin assets; the production stage installs only core, shared packages, and required or selected plugins. Staging a plugin manifest does not select that plugin for the runtime image.

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 Apus 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 "APUS_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}" \
    --build-arg "APUS_EXTENSIONS=${selected_plugin}" \
    --build-arg APUS_INSTALL_BROWSER= \
    --provenance=mode=max \
    --sbom=true \
    --tag "${REGISTRY}/apus-${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}/apus-clickclack:${SOURCE_SHA}"
# Deploy: registry.example.com/fakeco/apus-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 Apus npm tarball and pins the Node, archive, and manifest digests. Build that appliance independently from the same landed Apus source.

To test bundled plugin source against a packaged image, mount one plugin source directory over its packaged source path, e.g. APUS_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. Restart the Gateway after adding or changing a mount; runtime loading and setup use the mounted source.

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 APUS_EXTENSIONS="diagnostics-otel"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector:4318"
export OTEL_SERVICE_NAME="apus-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 check 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 check 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 apus-gateway sh -lc 'node dist/index.js gateway health --token "$APUS_GATEWAY_TOKEN"'

Detailed topics

Environment variables

The full variable table, apt/pip build extras, and build-memory tuning.

Networking and storage

LAN vs loopback, host.docker.internal, Claude CLI, Bonjour, and mounted state.

Compose operations

Compose command table, sandbox/CI/DNS/EACCES accordions, and image refreshes.

Sandbox and troubleshooting

Enabling the agent sandbox plus the Docker troubleshooting accordions.

Related