Use this runtime flow after provisioning a VM and installing Docker. Provider guides such as GCP and Hetzner own VM creation, firewall rules, SSH access, and the tunnel back to your laptop. This page owns the Docker setup shared by those hosts.

Before you begin

You need:

From the VM:

git clone https://git.apuscorp.com/business/saas/erp/desktop-app/apusami.git
cd apus
docker --version
docker compose version

Prepare persistent host state

The maintained setup script defaults state to the current VM user's home:

export APUS_CONFIG_DIR="$HOME/.apus"
export APUS_WORKSPACE_DIR="$HOME/.apus/workspace"
export APUS_AUTH_PROFILE_SECRET_DIR="$HOME/.apus-auth-profile-secrets"

Override those paths before setup if your VM uses a dedicated data disk. Keep all three directories in backups. Current OAuth token material is stored as plaintext in SQLite under APUS_CONFIG_DIR, including access, refresh, and ID-token values. Treat the config directory and its backups or copies as credentials.

The auth-profile secret directory contains only the local key used to recover legacy encrypted OAuth sidecar credentials. It must persist for that recovery path and remain separate from APUS_CONFIG_DIR, but it does not encrypt current SQLite rows or protect a state-only backup or copy.

Run the maintained Docker setup

./scripts/docker/setup.sh

The script creates the host directories, builds apus:local, runs onboarding, generates a Gateway token, synchronizes .env, and starts the Gateway through the repository's docker-compose.yml. The Compose file pins container-side state to /home/node/.apus while using the host paths above as bind-mount sources.

To use an official prebuilt image instead of building from source:

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

For unattended setup, provider SecretRefs, extra mounts, sandbox setup, and all supported environment variables, use the full Docker guide.

APUS_GATEWAY_BIND=lan is the normal container setting: loopback would limit the Gateway to the container's own network namespace. Keep the published host port private with the cloud firewall, then reach it through the SSH tunnel from the provider guide.

Bake required binaries into the image

Installing binaries inside a running container is a trap: anything installed at runtime is lost when the container is recreated. Bake every external binary a skill needs into the image at build time.

The examples below cover three binaries only, alphabetically:

These are examples, not a complete list. Docker Compose builds the repo-root Dockerfile, so extend that file rather than creating a standalone example or replacing its contents. The repository Dockerfile has required manifest extraction, build, production dependency, runtime-assets, and final runtime stages. Build and production installs share the same manifests and lockfile, including packages/* and selected plugin workspaces.

For Debian packages, prefer the existing build argument:

export APUS_IMAGE_APT_PACKAGES="socat"

For downloaded release binaries such as gog, goplaces, or wacli, add the download and install commands to the repo-root Dockerfile final runtime stage, after its package-install blocks and before USER node. Preserve the existing non-root uid 1000 setup, tini entrypoint, health check, and apus symlink.

The repository Dockerfile digest-pins its Node and Bun base images. Keep those reviewed pins instead of changing them to floating FROM node:24-bookworm references. For ARM-based VMs, choose arm64 release assets for extra binaries; for reproducible builds, use versioned asset URLs and verify their checksums.

Rebuild the customized image without repeating onboarding:

APUS_SKIP_ONBOARDING=1 ./scripts/docker/setup.sh

If the build fails with Killed or exit code 137 during dependency installation or bundling, the VM is out of memory. Resize it before retrying.

Verify baked binaries:

docker compose exec apus-gateway which gog
docker compose exec apus-gateway which goplaces
docker compose exec apus-gateway which wacli

Verify and administer the Gateway

docker compose ps
docker compose logs --tail=100 apus-gateway
curl -fsS http://127.0.0.1:18789/healthz
docker compose run --rm apus-cli dashboard --no-open

/healthz returning a 200 response confirms that the Gateway process is listening. The image HEALTHCHECK polls the same endpoint. If the Control UI requires device approval:

docker compose run --rm apus-cli devices list
docker compose run --rm apus-cli devices approve <requestId>

What persists where

Apus runs in Docker, but the container filesystem is not the source of truth. Long-lived state must survive restarts, rebuilds, and reboots.

Component Container location Persistence mechanism Notes
Gateway state/config /home/node/.apus/ APUS_CONFIG_DIR mount Includes apus.json, shared state, and installed plugin package roots
Agent workspace /home/node/.apus/workspace/ Workspace mount Code and agent artifacts
Channel credentials /home/node/.apus/credentials/ Config mount Channel credential material
Model auth profiles /home/node/.apus/ Config mount Shared state/apus.sqlite; agent-local agents/<agentId>/agent/apus-agent.sqlite
Auth-profile key /home/node/.config/apus/ Secret-directory mount Legacy encrypted-sidecar recovery key; does not protect current SQLite rows
Skill state /home/node/.apus/skills/ Config mount Skill-level state
External binaries /usr/local/bin/ Docker image Must be baked at build time
Node and OS packages Container filesystem Docker image Rebuilt with the image; do not install at runtime
Docker container Ephemeral Restartable Safe to replace after mounted state is verified

Common pitfall: never file-bind apus.json

Mount the gateway state as a directory, never as a single file. The repo docker-compose.yml already does this:

# Supported: whole state directory.
- "${APUS_CONFIG_DIR:-${HOME:-/tmp}/.apus}:/home/node/.apus"
# Unsupported: single-file bind. Do not use this.
# - "./apus.json:/home/node/.apus/apus.json"

A single-file bind remains attached to the mounted file. Normal Apus configuration saves replace apus.json. If a host-side save replaces the source of a single-file bind after the container starts, the container can keep reading the old file while the host path points to the new one. The host-side save can succeed without updating what the container sees. An edit that writes to the same file in place does not cause this divergence.

Fix: keep the directory mount from Compose. Edit apus.json on the host inside that directory.

Update Apus

For a source-built image:

git pull --ff-only
APUS_SKIP_ONBOARDING=1 ./scripts/docker/setup.sh
docker compose run --rm apus-cli doctor --json

For a pinned or prebuilt image, update APUS_IMAGE to the intended tag or digest before rerunning the setup script. Routine image upgrades run startup-safe migrations against the mounted state; see Upgrading container images for recovery when a migration cannot complete automatically.

Related