Ctrl K
ComunidadGuías…

docker-compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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: