docker-compose.yml
Rama main · réplica de lectura del repositorio original.
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 <host>
- "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: