services: db-prod: container_name: alexclaw-db-prod image: pgvector/pgvector:pg17 restart: unless-stopped environment: # The owner. Used only when the volume is first initialised; the # application role is created by the operator — see the upgrade guide. POSTGRES_USER: ${DATABASE_OWNER_USERNAME:-alexclaw} POSTGRES_PASSWORD: ${DATABASE_OWNER_PASSWORD} POSTGRES_DB: alex_claw_prod # For db-init/01-app-role.sh, which creates the application role on a # fresh volume. An existing volume ignores both. DATABASE_APP_USERNAME: "${DATABASE_USERNAME:?not set - the application role}" DATABASE_APP_PASSWORD: "${DATABASE_PASSWORD:?not set - the application role's password}" # Network connections only from the app and the migrate job, by their # pinned addresses below: another network's traffic can arrive from the # default network's gateway on some engines, so a subnet rule is not enough. command: ["postgres", "-c", "hba_file=/etc/postgresql/pg_hba.conf"] volumes: - pgdata:/var/lib/postgresql/data - ./db-init/01-app-role.sh:/docker-entrypoint-initdb.d/01-app-role.sh:ro - ./db-init/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DATABASE_OWNER_USERNAME:-alexclaw}"] interval: 5s timeout: 3s retries: 5 # Migrations run here, as the database owner, and nowhere else. This is the # only container given the owner's credentials: it migrates, grants the # application role its privileges (AlexClaw.Database.Roles), and exits. The # application starts only once it has exited cleanly, and connects as the # application role — which it checks at boot. migrate: container_name: alexclaw-migrate image: alexclaw-app:latest build: context: . args: BUILD_NUMBER: ${BUILD_NUMBER:-0} command: ["bin/alex_claw", "eval", "AlexClaw.Release.migrate()"] restart: "no" read_only: true tmpfs: - /tmp cap_drop: - ALL security_opt: - no-new-privileges:true networks: default: # Named in db-init/pg_hba.conf: change both together. ipv4_address: 10.213.61.11 depends_on: db-prod: condition: service_healthy environment: DATABASE_USERNAME: ${DATABASE_OWNER_USERNAME:-alexclaw} DATABASE_PASSWORD: "${DATABASE_OWNER_PASSWORD:?not set - the database owner's password}" DATABASE_HOSTNAME: db-prod DATABASE_APP_USERNAME: "${DATABASE_USERNAME:?not set - the application role}" # No SECRET_KEY_BASE, deliberately: the owner's credentials and the key # that decrypts the stored secrets never share a container. The runtime # configuration accepts its absence for an eval only. CLUSTER_COOKIE: "${CLUSTER_COOKIE:?not set - generate one with: openssl rand -base64 32}" RELEASE_TMP: /tmp ERL_CRASH_DUMP: /tmp/erl_crash.dump alexclaw-prod: container_name: alexclaw-prod # The BEAM turns SIGTERM into an orderly System.stop/0, but Docker's default # grace period is 10 seconds and the supervision tree needs longer: every # stop was reaching SIGKILL, so Reasoning.Loop's terminate/2 never ran and # in-flight workflow runs stayed recorded as executing. stop_grace_period: 30s # Nothing writes to the image. The three places that do write are mounted: # the skills directory, the backup directory, and a tmpfs for /tmp — which # is also where the release puts its generated runtime config and where a # crash dump goes. read_only: true tmpfs: - /tmp # The app opens a listening socket above 1024 and talks to postgres. It # needs nothing the kernel gates behind a capability. cap_drop: - ALL security_opt: - no-new-privileges:true image: alexclaw-app:latest build: context: . args: BUILD_NUMBER: ${BUILD_NUMBER:-0} hostname: node1.local restart: unless-stopped # A ceiling, so the app can never take the host down with it. The app sits # near 200 MiB; the headroom is for compiling generated skills and bursts. # Local models run on the host, outside this limit. mem_limit: 2g cpus: 2.0 # The database is on `default`; the web-automator only on `automation`. # The app is the one service on both. networks: default: # Named in db-init/pg_hba.conf: change both together. ipv4_address: 10.213.61.10 automation: {} # Its address is the one OpenBao's AppRole is bound to (openbao-init). vault: ipv4_address: 10.213.63.10 # Never the demo network here: docker-compose.demo.yml adds it, for the # SQL demo only. depends_on: # Started, not waited for: AlexClaw runs without OpenBao, and says so. openbao: condition: service_started required: false migrate: condition: service_completed_successfully automator-token-init: condition: service_completed_successfully ports: # Loopback by default. The admin UI carries session cookies and the MCP # endpoint carries bearer tokens, both in clear over HTTP, so the # supported way to reach it from anywhere else is a reverse proxy # terminating TLS — which is what docs/deployment/reverse-proxy.md # already assumes when it proxies to 127.0.0.1:5001. # # ADMIN_BIND=0.0.0.0 opens it to the network for anyone who means to. - "${ADMIN_BIND:-127.0.0.1}:${ADMIN_PORT:-5001}:5001" extra_hosts: - "host.docker.internal:host-gateway" environment: # Database # The application role, never the owner: the boot refuses the owner. DATABASE_USERNAME: "${DATABASE_USERNAME:?not set - the application role}" DATABASE_PASSWORD: ${DATABASE_PASSWORD} DATABASE_HOSTNAME: db-prod POOL_SIZE: ${POOL_SIZE:-10} # Application SECRET_KEY_BASE: ${SECRET_KEY_BASE} TOTP_ISSUER: ${TOTP_ISSUER:-AlexClaw} ADMIN_PASSWORD: ${ADMIN_PASSWORD} # The origins the LiveView socket accepts: loopback on the published # port, plus PHX_HOST behind a proxy; CHECK_ORIGIN replaces the list. ADMIN_PORT: ${ADMIN_PORT:-5001} PHX_HOST: ${PHX_HOST:-} CHECK_ORIGIN: ${CHECK_ORIGIN:-} # Telegram TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID} # Local models (optional) OLLAMA_ENABLED: ${OLLAMA_ENABLED:-false} OLLAMA_HOST: ${OLLAMA_HOST:-http://host.docker.internal:11434} OLLAMA_MODEL: ${OLLAMA_MODEL:-} LMSTUDIO_ENABLED: ${LMSTUDIO_ENABLED:-false} LMSTUDIO_HOST: ${LMSTUDIO_HOST:-http://host.docker.internal:1234} LMSTUDIO_MODEL: ${LMSTUDIO_MODEL:-} # Google OAuth (optional) — seeded into Admin > Config on first boot GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-} GOOGLE_OAUTH_REDIRECT_URI: ${GOOGLE_OAUTH_REDIRECT_URI:-} # Web automator (optional) WEB_AUTOMATOR_ENABLED: ${WEB_AUTOMATOR_ENABLED:-false} WEB_AUTOMATOR_HOST: ${WEB_AUTOMATOR_HOST:-http://web-automator:6900} # The token shared with the sidecar, which refuses every route but # /health without it: generated once by automator-token-init, read here. WEB_AUTOMATOR_TOKEN_FILE: /run/alexclaw/automator/token # Clustering NODE_NAME: ${NODE_NAME:-alexclaw@node1.local} CLUSTER_COOKIE: "${CLUSTER_COOKIE:?not set - generate one with: openssl rand -base64 32}" # Dynamic skills SKILLS_DIR: /app/skills # The release's scratch directory defaults to /app/tmp, which is on the # read-only filesystem. Nothing writes there on an ordinary boot — that # was checked — but the daemon and remote subcommands do, and a default # pointing at an unwritable path is a trap rather than a decision. RELEASE_TMP: /tmp ERL_CRASH_DUMP: /tmp/erl_crash.dump # OpenBao: where it answers, and where openbao-init leaves AlexClaw's # AppRole credentials and the CA certificate to verify it with. OPENBAO_ADDR: https://openbao:8200 OPENBAO_BOOTSTRAP_DIR: /run/alexclaw/openbao volumes: - skills_data:/app/skills # The only OpenBao mount AlexClaw has: role_id, secret_id, ca.pem. - openbao_bootstrap:/run/alexclaw/openbao:ro # The web automator's token, and nothing else. - automator_token:/run/alexclaw/automator:ro - ${BACKUP_DIR:-./backups}:/app/backups healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5001/health || exit 1"] interval: 10s timeout: 5s retries: 5 start_period: 30s # Opt-in: built and started only with `--profile web-automation` (or # COMPOSE_PROFILES=web-automation in .env). A first run does not build it. web-automator: profiles: ["web-automation"] build: ./web-automator restart: unless-stopped # A ceiling for the automation browser, which a heavy page can inflate. mem_limit: 2g cpus: 2.0 # As little as possible: not root, no capabilities, no privilege gain, and # nothing writable but a tmpfs /tmp (browser profile, X socket, downloads). user: "10001:10001" read_only: true tmpfs: - /tmp:size=512m,mode=1777 cap_drop: - ALL security_opt: - "no-new-privileges:true" ports: # noVNC gives unauthenticated control of the automation browser, so it is # bound to loopback. Reach it over an SSH tunnel rather than the LAN: # ssh -L 6080:127.0.0.1:6080 - "127.0.0.1:6080:6080" # Never on `default`, where the database is: the browser loads pages # AlexClaw does not control. networks: - automation environment: DISPLAY: ":99" WEB_AUTOMATOR_TOKEN_FILE: /run/automator/token volumes: # The token shared with AlexClaw, read at each request; nothing else. - automator_token:/run/automator:ro depends_on: automator-token-init: condition: service_completed_successfully healthcheck: test: ["CMD", "curl", "-f", "http://localhost:6900/health"] interval: 10s timeout: 5s retries: 3 # Fixed subnets, so db-init/pg_hba.conf can name the database's clients. # Chosen to stay clear of common home LANs (192.168.x), Tailscale (100.64.0.0/10) # and Docker's automatic pool (172.17-31.x). If they collide with a network of # yours, change them here and the pinned addresses with them — alexclaw-prod, # migrate, and the two lines of db-init/pg_hba.conf. # Opt-in: the SQL demo's database (docs/demo/sql-demo.md), started only with # `--profile demo`. Generated company data, never real: it is created again # at every start, in a tmpfs, from demo/initdb. Reached by AlexClaw alone, # on the internal `demo` network — which AlexClaw joins only when # docker-compose.demo.yml is given — as the read-only role alexclaw_reader. demo-db: profiles: ["demo"] image: postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24 restart: unless-stopped mem_limit: 512m cpus: 1.0 user: "70:70" read_only: true tmpfs: - /tmp:size=64m,mode=1777 - /var/run/postgresql:size=1m,uid=70,gid=70,mode=0775 - /var/lib/postgresql/data:size=512m,uid=70,gid=70,mode=0700 cap_drop: - ALL security_opt: - "no-new-privileges:true" command: ["postgres", "-c", "hba_file=/etc/postgresql/pg_hba.conf"] environment: POSTGRES_DB: demo # The superuser has no password: demo/pg_hba.conf admits it only on the # container's own socket, which is where the init scripts run. POSTGRES_HOST_AUTH_METHOD: trust # The read-only role's password; the same value goes into the connection # form, which keeps it in OpenBao. No default: the init refuses an empty one. DEMO_READER_PASSWORD: ${DEMO_READER_PASSWORD:-} volumes: - ./demo/initdb:/docker-entrypoint-initdb.d:ro - ./demo/reader.sql:/demo/reader.sql:ro - ./demo/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro networks: demo: ipv4_address: 10.213.65.20 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d demo"] interval: 10s timeout: 5s retries: 5 # One-shot, on every start: the token AlexClaw and the web automator share, # generated once into its own volume. Only those two mount it, read-only: # the sidecar never sees AlexClaw's OpenBao credentials, and no operator # sets or copies the token. An existing token is kept. automator-token-init: image: alexclaw-app:latest user: "0:0" entrypoint: ["/bin/sh", "-c"] command: - >- test -s /token/token || { head -c 32 /dev/urandom | base64 | tr -d '\n=' > /token/token.new && chmod 0444 /token/token.new && mv /token/token.new /token/token; } read_only: true network_mode: none cap_drop: - ALL security_opt: - no-new-privileges:true restart: "no" volumes: - automator_token:/token # Holds every secret AlexClaw uses (docs/architecture/openbao.md). Nothing is # published; only AlexClaw and the init service join its network. openbao: image: openbao/openbao:2.6.3@sha256:a60afafda36337abe833c4a63894bf1095098f29abea4091e7e555a33dd52889 user: "100:1000" read_only: true # A snapshot restore stages the file here; nothing else is written to it. tmpfs: - /tmp cap_drop: - ALL security_opt: - no-new-privileges:true restart: unless-stopped mem_limit: 512m cpus: 1.0 command: ["server", "-config=/openbao/config/config.hcl"] environment: # The volumes are mounted with the right owner; the image's entrypoint # must not try to chown them on a read-only root. SKIP_CHOWN: "1" BAO_API_ADDR: https://openbao:8200 BAO_CLUSTER_ADDR: https://openbao:8201 volumes: - openbao_data:/openbao/file - openbao_tls:/openbao/tls:ro - ./openbao/config.hcl:/openbao/config/config.hcl:ro # 32 random bytes, made on the host before the first start # (docs/reference/env-vars.md). Mounted by nothing else. - type: bind source: ${OPENBAO_UNSEAL_DIR:-./openbao/unseal} target: /openbao/unseal read_only: true networks: vault: ipv4_address: 10.213.63.2 depends_on: # Healthy once the TLS certificate is in place. openbao-init: condition: service_healthy # One-shot, on every start of the stack: the TLS certificate (while OpenBao is # not running), then — once — OpenBao's initialisation. The first # initialisation needs an operator at a terminal: # docker compose run --rm openbao-init openbao-init: image: alexclaw-openbao-init:latest build: context: ./openbao dockerfile: init.Dockerfile read_only: true tmpfs: - /tmp cap_drop: - ALL # Root, to hand the TLS key to OpenBao's user and the credentials to the # group it shares with AlexClaw. cap_add: - CHOWN - DAC_OVERRIDE - FOWNER security_opt: - no-new-privileges:true restart: "no" environment: OPENBAO_ADDR: https://openbao:8200 OPENBAO_HOSTNAME: openbao ALEXCLAW_ADDRESS: 10.213.63.10 BACKUP_ADDRESS: 10.213.63.11 volumes: - openbao_tls:/openbao/tls - openbao_bootstrap:/bootstrap - openbao_backup:/backup-credentials networks: - vault healthcheck: test: ["CMD", "test", "-f", "/tmp/ready"] interval: 1s timeout: 2s retries: 60 # On demand only (profile "backup", started by scripts/backup-openbao.sh): a # raft snapshot of OpenBao into the host's backups directory, taken with the # backup AppRole — which may read a snapshot and nothing else. Its # credentials are on openbao_backup, which AlexClaw never mounts: AlexClaw # cannot copy its own vault. openbao-backup: image: alexclaw-openbao-init:latest build: context: ./openbao dockerfile: init.Dockerfile profiles: ["backup"] entrypoint: ["/usr/local/bin/openbao-backup"] read_only: true tmpfs: - /tmp cap_drop: - ALL security_opt: - no-new-privileges:true restart: "no" # BACKUP_NAME is given by the script (docker compose run -e). environment: OPENBAO_ADDR: https://openbao:8200 volumes: - openbao_backup:/credentials:ro # Not BACKUP_DIR: that is the db_backup skill's directory, which AlexClaw # mounts. The snapshots go where AlexClaw never sees them. - type: bind source: ${OPENBAO_BACKUP_DIR:-./openbao-backups} target: /backups networks: vault: ipv4_address: 10.213.63.11 networks: default: ipam: config: - subnet: 10.213.61.0/24 # Addresses given out automatically come from the upper half, away # from the pinned ones. ip_range: 10.213.61.128/25 automation: ipam: config: - subnet: 10.213.62.0/24 # The demo database, and AlexClaw only when docker-compose.demo.yml is # given; no route out. Its subnet overlaps no other network, the test # stack's included (10.213.64.0/24 is its vault network). demo: internal: true ipam: config: - subnet: 10.213.65.0/24 ip_range: 10.213.65.128/25 # OpenBao and AlexClaw only; no route out. vault: internal: true ipam: config: - subnet: 10.213.63.0/24 # Automatic addresses (the init service) from the upper half, away # from the pinned ones. ip_range: 10.213.63.128/25 volumes: pgdata: automator_token: skills_data: openbao_data: openbao_tls: openbao_bootstrap: openbao_backup: