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}" volumes: - pgdata:/var/lib/postgresql/data - ./db-init/01-app-role.sh:/docker-entrypoint-initdb.d/01-app-role.sh: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 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 node1: # 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 read_only: true tmpfs: - /tmp 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 a node can never take the host down with it. Local models # run on the host, outside this limit. mem_limit: 2g cpus: 2.0 depends_on: migrate: condition: service_completed_successfully ports: - "${ADMIN_BIND:-127.0.0.1}:5001:5001" extra_hosts: - "host.docker.internal:host-gateway" environment: # 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 SECRET_KEY_BASE: ${SECRET_KEY_BASE} ADMIN_PASSWORD: ${ADMIN_PASSWORD} # The published port above: the origin the browser sends. ADMIN_PORT: "5001" PHX_HOST: ${PHX_HOST:-} CHECK_ORIGIN: ${CHECK_ORIGIN:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID} 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:-} NODE_NAME: alexclaw@node1.local CLUSTER_COOKIE: "${CLUSTER_COOKIE:?not set - generate one with: openssl rand -base64 32}" SKILLS_DIR: /app/skills RELEASE_TMP: /tmp ERL_CRASH_DUMP: /tmp/erl_crash.dump volumes: - skills_data:/app/skills node2: # 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 read_only: true tmpfs: - /tmp cap_drop: - ALL security_opt: - no-new-privileges:true image: alexclaw-app:latest build: context: . args: BUILD_NUMBER: ${BUILD_NUMBER:-0} hostname: node2.local restart: unless-stopped # A ceiling, so a node can never take the host down with it. Local models # run on the host, outside this limit. mem_limit: 2g cpus: 2.0 depends_on: migrate: condition: service_completed_successfully ports: - "${ADMIN_BIND:-127.0.0.1}:5002:5001" extra_hosts: - "host.docker.internal:host-gateway" environment: # 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 SECRET_KEY_BASE: ${SECRET_KEY_BASE} ADMIN_PASSWORD: ${ADMIN_PASSWORD} # The published port above: the origin the browser sends. ADMIN_PORT: "5002" PHX_HOST: ${PHX_HOST:-} CHECK_ORIGIN: ${CHECK_ORIGIN:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID} 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:-} NODE_NAME: alexclaw@node2.local CLUSTER_COOKIE: "${CLUSTER_COOKIE:?not set - generate one with: openssl rand -base64 32}" SKILLS_DIR: /app/skills RELEASE_TMP: /tmp ERL_CRASH_DUMP: /tmp/erl_crash.dump volumes: - skills_data:/app/skills volumes: pgdata: skills_data: