Ctrl K
ComunidadGuías…

docker-compose.yml

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

Archivo original ↗Preguntar a KiBot
name: hermes-9router-stack

services:
  nine-router:
    profiles: ["9router"]
    image: ${NINEROUTER_IMAGE_REPOSITORY:-decolua/9router}:${NINEROUTER_IMAGE_TAG:-latest}
    container_name: nine-router
    restart: unless-stopped
    ports:
      - "${NINEROUTER_BIND_IP:-127.0.0.1}:${NINEROUTER_PORT:-20128}:20128"
    volumes:
      - ./data/9router:/app/data
    environment:
      DATA_DIR: /app/data
      PORT: "20128"
      HOSTNAME: "0.0.0.0"
      NODE_ENV: production
      JWT_SECRET: ${NINEROUTER_JWT_SECRET}
      INITIAL_PASSWORD: ${NINEROUTER_INITIAL_PASSWORD}
      API_KEY_SECRET: ${NINEROUTER_API_KEY_SECRET}
      MACHINE_ID_SALT: ${NINEROUTER_MACHINE_ID_SALT}
      REQUIRE_API_KEY: ${NINEROUTER_REQUIRE_API_KEY:-false}
      AUTH_COOKIE_SECURE: ${NINEROUTER_AUTH_COOKIE_SECURE:-false}
      ENABLE_REQUEST_LOGS: ${NINEROUTER_ENABLE_REQUEST_LOGS:-false}
      OBSERVABILITY_ENABLED: ${NINEROUTER_OBSERVABILITY_ENABLED:-true}
      BASE_URL: http://nine-router:20128
      NEXT_PUBLIC_BASE_URL: ${NINEROUTER_PUBLIC_BASE_URL:-http://localhost:20128}
      CLOUD_URL: ${NINEROUTER_CLOUD_URL:-https://9router.com}
      NEXT_PUBLIC_CLOUD_URL: ${NINEROUTER_CLOUD_URL:-https://9router.com}
    networks: [agent-net]
    healthcheck:
      test:
        - CMD
        - node
        - -e
        - "fetch('http://127.0.0.1:20128').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 40s

  # Alternative router backend: OmniRoute (profile "omniroute"). It exposes
  # the dashboard on 20128 and its OpenAI-compatible API on 20129. Installers
  # enable either the "9router" or the "omniroute" profile, never both; the
  # Smart Router upstream URLs are written into .env for the selected backend.
  omniroute:
    profiles: ["omniroute"]
    image: ${OMNIROUTE_IMAGE_REPOSITORY:-diegosouzapw/omniroute}:${OMNIROUTE_IMAGE_TAG:-latest}
    container_name: omniroute
    restart: unless-stopped
    stop_grace_period: 40s
    ports:
      - "${OMNIROUTE_BIND_IP:-127.0.0.1}:${OMNIROUTE_PORT:-20128}:20128"
      - "${OMNIROUTE_API_BIND_IP:-127.0.0.1}:${OMNIROUTE_API_PORT:-20129}:20129"
    volumes:
      - ./data/omniroute:/app/data
    environment:
      DATA_DIR: /app/data
      PORT: "20128"
      DASHBOARD_PORT: "20128"
      API_PORT: "20129"
      API_HOST: "0.0.0.0"
      HOSTNAME: "0.0.0.0"
      NODE_ENV: production
      # The installer (install.sh / ./manage.sh configure) writes these secrets
      # into .env for every install, so the empty fallbacks below only keep
      # Docker Compose from warning when the "omniroute" profile is not the
      # selected backend. The omniroute service itself still requires real
      # values before it is started.
      JWT_SECRET: ${OMNIROUTE_JWT_SECRET:-}
      INITIAL_PASSWORD: ${OMNIROUTE_INITIAL_PASSWORD:-}
      API_KEY_SECRET: ${OMNIROUTE_API_KEY_SECRET:-}
      OMNIROUTE_API_KEY: ${OMNIROUTE_MANAGEMENT_API_KEY:-}
      STORAGE_ENCRYPTION_KEY: ${OMNIROUTE_STORAGE_ENCRYPTION_KEY:-}
      STORAGE_ENCRYPTION_KEY_VERSION: ${OMNIROUTE_STORAGE_ENCRYPTION_KEY_VERSION:-v1}
      MACHINE_ID_SALT: ${OMNIROUTE_MACHINE_ID_SALT:-}
      OMNIROUTE_WS_BRIDGE_SECRET: ${OMNIROUTE_WS_BRIDGE_SECRET:-}
      REQUIRE_API_KEY: ${OMNIROUTE_REQUIRE_API_KEY:-false}
      AUTH_COOKIE_SECURE: ${OMNIROUTE_AUTH_COOKIE_SECURE:-false}
      NEXT_PUBLIC_BASE_URL: ${OMNIROUTE_PUBLIC_BASE_URL:-http://localhost:20128}
      BASE_URL: http://omniroute:20128
      ALLOW_API_KEY_REVEAL: ${OMNIROUTE_ALLOW_API_KEY_REVEAL:-false}
      OMNIROUTE_MEMORY_MB: ${OMNIROUTE_MEMORY_MB:-512}
    networks: [agent-net]
    healthcheck:
      test:
        - CMD
        - node
        - -e
        - "fetch('http://127.0.0.1:20128').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 40s

  # Waits for the active router backend (9router or OmniRoute) before the Smart
  # Router starts. The health URL is resolved from SMART_ROUTER_UPSTREAM_HEALTH_URL,
  # which install.sh points at the selected backend, so this init probe works
  # with both the "9router" and "omniroute" profiles.
  router-upstream-probe:
    profiles: ["smart-router"]
    image: alpine:3.22
    container_name: hermes-router-upstream-probe
    environment:
      ROUTER_HEALTH_URL: ${SMART_ROUTER_UPSTREAM_HEALTH_URL:-http://nine-router:20128/api/health}
    command:
      - sh
      - -c
      - |
        for attempt in $(seq 1 90); do
          if wget -q -O /dev/null "$$ROUTER_HEALTH_URL"; then
            exit 0
          fi
          sleep 2
        done
        echo "router backend did not become healthy: $$ROUTER_HEALTH_URL" >&2
        exit 1
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  smart-router-init:
    profiles: ["smart-router"]
    image: alpine:3.22
    container_name: hermes-smart-router-init
    user: "0:0"
    command: ["sh", "-c", "mkdir -p /data && chown 10001:10001 /data && chmod 0700 /data"]
    volumes:
      - ./data/smart-router:/data
    cap_drop: ["ALL"]
    cap_add: ["CHOWN", "DAC_OVERRIDE", "FOWNER"]
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  smart-router:
    profiles: ["smart-router"]
    image: ${SMART_ROUTER_IMAGE_REPOSITORY:-afsharidevops/hermes-smart-router}:${SMART_ROUTER_IMAGE_TAG:-0.6.4}
    container_name: hermes-smart-router
    restart: unless-stopped
    ports:
      - "${SMART_ROUTER_BIND_IP:-127.0.0.1}:${SMART_ROUTER_PORT:-8787}:8080"
    environment:
      SMART_ROUTER_LEARNED_ERROR_FALLBACK: ${SMART_ROUTER_LEARNED_ERROR_FALLBACK:-heuristic}
      SMART_ROUTER_LEARNED_FALLBACK: ${SMART_ROUTER_LEARNED_FALLBACK:-standard}
      SMART_ROUTER_LEARNED_MIN_CONFIDENCE: ${SMART_ROUTER_LEARNED_MIN_CONFIDENCE:-0.70}
      SMART_ROUTER_LEARNED_METADATA_FILE: ${SMART_ROUTER_LEARNED_METADATA_FILE:-/policy/learned-v4.json}
      SMART_ROUTER_LEARNED_MODEL_FILE: ${SMART_ROUTER_LEARNED_MODEL_FILE:-/policy/learned-v4.joblib}
      SMART_ROUTER_STRONG_MAX_CONTEXT: ${SMART_ROUTER_STRONG_MAX_CONTEXT:-200000}
      SMART_ROUTER_STANDARD_MAX_CONTEXT: ${SMART_ROUTER_STANDARD_MAX_CONTEXT:-128000}
      SMART_ROUTER_FAST_MAX_CONTEXT: ${SMART_ROUTER_FAST_MAX_CONTEXT:-32000}
      SMART_ROUTER_PREFERRED_TOKEN_FIELD: ${SMART_ROUTER_PREFERRED_TOKEN_FIELD:-max_tokens}
      SMART_ROUTER_CONTEXT_TOKEN_SAFETY_FACTOR: ${SMART_ROUTER_CONTEXT_TOKEN_SAFETY_FACTOR:-1.15}
      SMART_ROUTER_ALLOW_TIER_OVERRIDES: ${SMART_ROUTER_ALLOW_TIER_OVERRIDES:-true}
      SMART_ROUTER_ORCHESTRATOR_APPROVAL_MODE: ${SMART_ROUTER_ORCHESTRATOR_APPROVAL_MODE:-auto}
      SMART_ROUTER_ORCHESTRATOR_PLANNER_TIER: ${SMART_ROUTER_ORCHESTRATOR_PLANNER_TIER:-standard}
      SMART_ROUTER_ORCHESTRATOR_REVIEWER_TIER: ${SMART_ROUTER_ORCHESTRATOR_REVIEWER_TIER:-strong}
      SMART_ROUTER_COST_LEDGER_ENABLED: ${SMART_ROUTER_COST_LEDGER_ENABLED:-true}
      SMART_ROUTER_COST_DATABASE_PATH: ${SMART_ROUTER_COST_DATABASE_PATH:-/data/cost-ledger.sqlite3}
      SMART_ROUTER_PRICING_FILE: ${SMART_ROUTER_PRICING_FILE:-/policy/pricing-v0.5.json}
      SMART_ROUTER_DASHBOARD_ENABLED: ${SMART_ROUTER_DASHBOARD_ENABLED:-true}
      SMART_ROUTER_CONTROL_PLANE_ENABLED: ${SMART_ROUTER_CONTROL_PLANE_ENABLED:-true}
      SMART_ROUTER_CONTROL_DATABASE_URL: ${SMART_ROUTER_CONTROL_DATABASE_URL:-sqlite:////data/control-v0.5.2.sqlite3}
      SMART_ROUTER_ADMIN_API_KEY: ${SMART_ROUTER_ADMIN_API_KEY:-}
      SMART_ROUTER_ADMIN_API_KEY_FILE: ${SMART_ROUTER_ADMIN_API_KEY_FILE:-}
      SMART_ROUTER_BOOTSTRAP_ADMIN_PASSWORD_FILE: ${SMART_ROUTER_BOOTSTRAP_ADMIN_PASSWORD_FILE:-}
      SMART_ROUTER_PG_PASSWORD_FILE: ${SMART_ROUTER_PG_PASSWORD_FILE:-}
      SMART_ROUTER_REDIS_URL: ${SMART_ROUTER_REDIS_URL:-}
      SMART_ROUTER_REDIS_REQUIRED: ${SMART_ROUTER_REDIS_REQUIRED:-false}
      SMART_ROUTER_STICKY_BACKEND: ${SMART_ROUTER_STICKY_BACKEND:-auto}
      SMART_ROUTER_PROVIDER_HEALTH_ENABLED: ${SMART_ROUTER_PROVIDER_HEALTH_ENABLED:-true}
      SMART_ROUTER_CIRCUIT_FAILURE_THRESHOLD: ${SMART_ROUTER_CIRCUIT_FAILURE_THRESHOLD:-5}
      SMART_ROUTER_CIRCUIT_FAILURE_WINDOW_SECONDS: ${SMART_ROUTER_CIRCUIT_FAILURE_WINDOW_SECONDS:-60}
      SMART_ROUTER_CIRCUIT_COOLDOWN_SECONDS: ${SMART_ROUTER_CIRCUIT_COOLDOWN_SECONDS:-30}
      SMART_ROUTER_OIDC_ENABLED: ${SMART_ROUTER_OIDC_ENABLED:-false}
      SMART_ROUTER_OIDC_ISSUER_URL: ${SMART_ROUTER_OIDC_ISSUER_URL:-}
      SMART_ROUTER_OIDC_CLIENT_ID: ${SMART_ROUTER_OIDC_CLIENT_ID:-}
      SMART_ROUTER_OIDC_CLIENT_SECRET: ${SMART_ROUTER_OIDC_CLIENT_SECRET:-}
      SMART_ROUTER_OIDC_CLIENT_SECRET_FILE: ${SMART_ROUTER_OIDC_CLIENT_SECRET_FILE:-}
      SMART_ROUTER_OIDC_REDIRECT_URI: ${SMART_ROUTER_OIDC_REDIRECT_URI:-}
      SMART_ROUTER_OIDC_GROUP_ROLE_MAP: ${SMART_ROUTER_OIDC_GROUP_ROLE_MAP:-}
      SMART_ROUTER_OIDC_DEFAULT_ROLE: ${SMART_ROUTER_OIDC_DEFAULT_ROLE:-user}
      SMART_ROUTER_OIDC_AUTO_PROVISION: ${SMART_ROUTER_OIDC_AUTO_PROVISION:-true}
      SMART_ROUTER_OIDC_LOCAL_LOGIN_ENABLED: ${SMART_ROUTER_OIDC_LOCAL_LOGIN_ENABLED:-true}
      SMART_ROUTER_KNOWLEDGE_ACL_DEFAULT_DENY: ${SMART_ROUTER_KNOWLEDGE_ACL_DEFAULT_DENY:-false}
      SMART_ROUTER_BOOTSTRAP_ADMIN_USER: ${SMART_ROUTER_BOOTSTRAP_ADMIN_USER:-admin}
      SMART_ROUTER_BOOTSTRAP_ADMIN_PASSWORD: ${SMART_ROUTER_BOOTSTRAP_ADMIN_PASSWORD:-}
      SMART_ROUTER_REQUIRE_AUTH: ${SMART_ROUTER_REQUIRE_AUTH:-true}
      SMART_ROUTER_HA_MODE: ${SMART_ROUTER_HA_MODE:-false}
      SMART_ROUTER_CODING_MODEL: ${SMART_ROUTER_CODING_MODEL:-combo-strong}
      SMART_ROUTER_VISION_MODEL: ${SMART_ROUTER_VISION_MODEL:-combo-vision}
      SMART_ROUTER_ANON_RPM: ${SMART_ROUTER_ANON_RPM:-30}
      SMART_ROUTER_ANON_TPM: ${SMART_ROUTER_ANON_TPM:-200000}
      SMART_ROUTER_ANON_DAILY_REQUESTS: ${SMART_ROUTER_ANON_DAILY_REQUESTS:-1000}
      SMART_ROUTER_CLIENT_RPM: ${SMART_ROUTER_CLIENT_RPM:-120}
      SMART_ROUTER_CLIENT_TPM: ${SMART_ROUTER_CLIENT_TPM:-2000000}
      SMART_ROUTER_CLIENT_DAILY_REQUESTS: ${SMART_ROUTER_CLIENT_DAILY_REQUESTS:-10000}
      SMART_ROUTER_VIRTUAL_KEY_DEFAULT_RPM: ${SMART_ROUTER_VIRTUAL_KEY_DEFAULT_RPM:-60}
      SMART_ROUTER_VIRTUAL_KEY_DEFAULT_TPM: ${SMART_ROUTER_VIRTUAL_KEY_DEFAULT_TPM:-1000000}
      SMART_ROUTER_VIRTUAL_KEY_DEFAULT_DAILY_REQUESTS: ${SMART_ROUTER_VIRTUAL_KEY_DEFAULT_DAILY_REQUESTS:-5000}
      SMART_ROUTER_KNOWLEDGE_DATABASE_URL: ${SMART_ROUTER_KNOWLEDGE_DATABASE_URL:-}
      SMART_ROUTER_RAG_MODE: ${SMART_ROUTER_RAG_MODE:-hybrid}
      SMART_ROUTER_EMBEDDINGS_BASE_URL: ${SMART_ROUTER_EMBEDDINGS_BASE_URL:-}
      SMART_ROUTER_EMBEDDINGS_MODEL: ${SMART_ROUTER_EMBEDDINGS_MODEL:-text-embedding-3-small}
      SMART_ROUTER_EMBEDDINGS_API_KEY: ${SMART_ROUTER_EMBEDDINGS_API_KEY:-}
      SMART_ROUTER_EMBEDDINGS_DIMENSIONS: ${SMART_ROUTER_EMBEDDINGS_DIMENSIONS:-384}
      SMART_ROUTER_EMBEDDINGS_TIMEOUT_SECONDS: ${SMART_ROUTER_EMBEDDINGS_TIMEOUT_SECONDS:-20}
      SMART_ROUTER_GUARDRAILS_MODE: ${SMART_ROUTER_GUARDRAILS_MODE:-audit}
      SMART_ROUTER_ALLOWED_TOOLS: ${SMART_ROUTER_ALLOWED_TOOLS:-}
      SMART_ROUTER_GUARDRAIL_DENY_PATTERNS: ${SMART_ROUTER_GUARDRAIL_DENY_PATTERNS:-}
      SMART_ROUTER_SESSION_TTL_SECONDS_V51: ${SMART_ROUTER_SESSION_TTL_SECONDS_V51:-28800}
      SMART_ROUTER_CLIENT_API_KEY: ${SMART_ROUTER_CLIENT_API_KEY:-}
      SMART_ROUTER_UPSTREAM_API_KEY: ${SMART_ROUTER_UPSTREAM_API_KEY:-}
      SMART_ROUTER_UPSTREAM_HEALTH_URL: ${SMART_ROUTER_UPSTREAM_HEALTH_URL:-http://nine-router:20128/api/health}
      SMART_ROUTER_MODE: ${SMART_ROUTER_MODE:-observe}
      SMART_ROUTER_POLICY: ${SMART_ROUTER_POLICY:-heuristic}
      SMART_ROUTER_CALIBRATION_FILE: ${SMART_ROUTER_CALIBRATION_FILE:-/policy/calibrated.json}
      SMART_ROUTER_UPSTREAM_BASE_URL: ${SMART_ROUTER_UPSTREAM_BASE_URL:-http://nine-router:20128/v1}
      SMART_ROUTER_DATABASE_PATH: ${SMART_ROUTER_DATABASE_PATH:-/data/router.sqlite3}
      SMART_ROUTER_HMAC_SECRET: ${SMART_ROUTER_HMAC_SECRET}
      SMART_ROUTER_HMAC_SECRET_FILE: ${SMART_ROUTER_HMAC_SECRET_FILE:-}
      SMART_ROUTER_UPSTREAM_API_KEY_FILE: ${SMART_ROUTER_UPSTREAM_API_KEY_FILE:-}
      SMART_ROUTER_CLIENT_API_KEY_FILE: ${SMART_ROUTER_CLIENT_API_KEY_FILE:-}
      SMART_ROUTER_POLICY_VERSION: ${SMART_ROUTER_POLICY_VERSION:-4}
      SMART_ROUTER_OBSERVE_MODEL: ${SMART_ROUTER_OBSERVE_MODEL:-ai}
      SMART_ROUTER_FAST_MODEL: ${SMART_ROUTER_FAST_MODEL:-combo-fast}
      SMART_ROUTER_STANDARD_MODEL: ${SMART_ROUTER_STANDARD_MODEL:-combo-standard}
      SMART_ROUTER_STRONG_MODEL: ${SMART_ROUTER_STRONG_MODEL:-combo-strong}
      SMART_ROUTER_FAST_SUPPORTS_TOOLS: ${SMART_ROUTER_FAST_SUPPORTS_TOOLS:-false}
      SMART_ROUTER_FAST_SUPPORTS_VISION: ${SMART_ROUTER_FAST_SUPPORTS_VISION:-false}
      SMART_ROUTER_STANDARD_SUPPORTS_TOOLS: ${SMART_ROUTER_STANDARD_SUPPORTS_TOOLS:-true}
      SMART_ROUTER_STANDARD_SUPPORTS_VISION: ${SMART_ROUTER_STANDARD_SUPPORTS_VISION:-false}
      SMART_ROUTER_STRONG_SUPPORTS_TOOLS: ${SMART_ROUTER_STRONG_SUPPORTS_TOOLS:-true}
      SMART_ROUTER_STRONG_SUPPORTS_VISION: ${SMART_ROUTER_STRONG_SUPPORTS_VISION:-true}
      SMART_ROUTER_SESSION_TTL_SECONDS: ${SMART_ROUTER_SESSION_TTL_SECONDS:-2700}
      SMART_ROUTER_MAX_SESSION_AGE_SECONDS: ${SMART_ROUTER_MAX_SESSION_AGE_SECONDS:-43200}
      SMART_ROUTER_DEMOTION_TURNS: ${SMART_ROUTER_DEMOTION_TURNS:-5}
      SMART_ROUTER_FAST_MAX_TOKENS: ${SMART_ROUTER_FAST_MAX_TOKENS:-1024}
      SMART_ROUTER_STANDARD_MAX_TOKENS: ${SMART_ROUTER_STANDARD_MAX_TOKENS:-4096}
      SMART_ROUTER_STRONG_MAX_TOKENS: ${SMART_ROUTER_STRONG_MAX_TOKENS:-6144}
      SMART_ROUTER_CONNECT_TIMEOUT_SECONDS: ${SMART_ROUTER_CONNECT_TIMEOUT_SECONDS:-10}
      SMART_ROUTER_READ_TIMEOUT_SECONDS: ${SMART_ROUTER_READ_TIMEOUT_SECONDS:-600}
      SMART_ROUTER_MAX_REQUEST_BYTES: ${SMART_ROUTER_MAX_REQUEST_BYTES:-10485760}
      SMART_ROUTER_OBSERVATION_FILE: ${SMART_ROUTER_OBSERVATION_FILE:-/data/observations-v4.jsonl}
      SMART_ROUTER_TOOLS_REGISTRY: ${SMART_ROUTER_TOOLS_REGISTRY:-/policy-content/tools.json}
    volumes:
      - ./data/smart-router:/data
      - ./smart-router/policy:/policy:ro
      # Shared tool registry: the same file the Content Bot reads from its
      # policy directory. GET /v1/tools serves the entries marked for the router.
      - ./data/content-manager/config:/policy-content:ro
    read_only: true
    tmpfs:
      - /tmp:size=64m,mode=1777
    security_opt:
      - no-new-privileges:true
    cap_drop: ["ALL"]
    depends_on:
      router-upstream-probe:
        condition: service_completed_successfully
      smart-router-init:
        condition: service_completed_successfully
    networks: [agent-net]
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 10s

  # Repairs ownership of the Hermes logs subtree before the gateway starts.
  # On a warm volume, the upstream image's own UID-remap chown is skipped
  # whenever the top-level data directory already belongs to the Hermes uid,
  # so any previously root-created log files (e.g. from a manual `docker exec`
  # run as root) stay root-owned forever and crash the gateway with
  # PermissionError on every subsequent start. This stays scoped to
  # data/hermes/logs only; it never touches the rest of the Hermes data mount.
  hermes-init:
    profiles: ["hermes"]
    image: alpine:3.22
    container_name: hermes-init
    user: "0:0"
    command:
      - sh
      - -c
      - |
        set -e
        touch /logs/agent.log /logs/errors.log
        find /logs -type d -exec chown "${HERMES_UID:-10000}:${HERMES_GID:-10000}" {} +
        find /logs -type f -exec chown "${HERMES_UID:-10000}:${HERMES_GID:-10000}" {} +
        find /logs -type d -exec chmod 0700 {} +
        find /logs -type f -exec chmod 0600 {} +
    volumes:
      - ./data/hermes/logs:/logs
    cap_drop: ["ALL"]
    cap_add: ["CHOWN", "DAC_OVERRIDE", "FOWNER"]
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  hermes:
    profiles: ["hermes"]
    image: ${HERMES_IMAGE_REPOSITORY:-nousresearch/hermes-agent}:${HERMES_IMAGE_TAG:-latest}
    container_name: hermes-agent
    restart: unless-stopped
    command: gateway run
    depends_on:
      hermes-init:
        condition: service_completed_successfully
    shm_size: "1gb"
    ports:
      - "${HERMES_BIND_IP:-127.0.0.1}:${HERMES_API_PORT:-8642}:8642"
      - "${HERMES_BIND_IP:-127.0.0.1}:${HERMES_DASHBOARD_PORT:-9119}:9119"
    volumes:
      - ./data/hermes:/opt/data
      - ./plugins/stack-package-policy:/opt/data/plugins/stack-package-policy:ro
      - ./plugins/stack-execution-policy:/opt/data/plugins/stack-execution-policy:ro
      # Owned by the configured Hermes uid and broker group. Hermes gets only the
      # control secret and user policy; never the Docker socket or an SSH key.
      - ./data/stack-secrets/execution/control-secret:/run/secrets/execution-control:ro
      - ./data/stack-secrets/execution/users:/run/secrets/execution-users:ro
      - ./data/stack-secrets/execution/features:/run/secrets/execution-features:ro
    environment:
      HERMES_HOME: /opt/data
      EXECUTION_FEATURES: ${EXECUTION_FEATURES:-}
      EXECUTION_FEATURES_FILE: /run/secrets/execution-features
      EXECUTION_USERS_FILE: /run/secrets/execution-users
      EXECUTION_DOCKER_BROKER_URL: http://hermes-execution-docker-broker:8750
      EXECUTION_SSH_BROKER_URL: http://hermes-execution-ssh-broker:8750
      EXECUTION_CONTROL_SECRET_FILE: /run/secrets/execution-control
      HERMES_DASHBOARD: ${HERMES_DASHBOARD:-0}
      HERMES_DASHBOARD_BASIC_AUTH_USERNAME: ${HERMES_DASHBOARD_BASIC_AUTH_USERNAME:-}
      HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH: ${HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH:-}
      HERMES_DASHBOARD_BASIC_AUTH_SECRET: ${HERMES_DASHBOARD_BASIC_AUTH_SECRET:-}
      HERMES_UID: ${HERMES_UID:-10000}
      HERMES_GID: ${HERMES_GID:-10000}
      HERMES_DISABLE_LAZY_INSTALLS: "1"
      HERMES_LAZY_INSTALL_TARGET: /opt/data/lazy-packages
      NPM_CONFIG_PREFIX: /opt/data/npm-packages
      NODE_PATH: /opt/data/npm-packages/lib/node_modules
      PATH: /opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/data/npm-packages/bin
    extra_hosts:
      - "host.docker.internal:host-gateway"
    networks: [agent-net, execution-control-net]

  # The only service with the Docker socket. It has no published port and is
  # reachable only from Hermes on the internal control network, with the secret.
  execution-docker-broker:
    profiles: ["execution-docker"]
    image: ${EXECUTION_BROKER_IMAGE:-afsharidevops/hermes-execution-broker:0.1.3}
    container_name: hermes-execution-docker-broker
    restart: unless-stopped
    # Runs as the local operator so mode-0600 broker state stays unreadable to
    # every other uid, and joins the docker group only to reach the socket.
    user: "${EXECUTION_RUN_AS:-10003:10003}"
    group_add: ["${EXECUTION_DOCKER_GID:-65534}"]
    environment:
      BROKER_MODE: docker
      BROKER_STATE: /state/capabilities.sqlite3
      BROKER_CONTROL_SECRET_FILE: /run/secrets/execution-control
      BROKER_APPROVAL_REQUEST_SECRET_FILE: /run/secrets/execution-approval-request
      BROKER_APPROVAL_PUBLIC_KEY_FILE: /run/secrets/execution-approval-public-key
      BROKER_APPROVER_URL: http://execution-approver:8751
      EXECUTION_FEATURES: ${EXECUTION_FEATURES:-}
      EXECUTION_FEATURES_FILE: /run/config/execution-features
      EXECUTION_POLICY_GENERATION: ${EXECUTION_POLICY_GENERATION:-0}
      EXECUTION_POLICY_GENERATION_FILE: /run/config/execution-policy-generation
      EXECUTION_WORKSPACE: ${EXECUTION_WORKSPACE_HOST_PATH:-}
      EXECUTION_WORKSPACE_GENERATION: ${EXECUTION_WORKSPACE_GENERATION:-0}
      EXECUTION_EGRESS_NETWORK: hermes-execution-egress
      SANDBOX_IMAGE: ${EXECUTION_SANDBOX_IMAGE:-}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data/stack-secrets/execution/docker-state:/state
      - ./data/stack-secrets/execution/control-secret:/run/secrets/execution-control:ro
      - ./data/stack-secrets/execution/approval-request-secret:/run/secrets/execution-approval-request:ro
      - ./data/stack-secrets/execution/approval-public-key.pem:/run/secrets/execution-approval-public-key:ro
      - ./data/stack-secrets/execution/features:/run/config/execution-features:ro
      - ./data/stack-secrets/execution/policy-generation:/run/config/execution-policy-generation:ro
    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=0700,uid=10003,gid=10003
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    pids_limit: 128
    networks: [execution-control-net]
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8750/health', timeout=3)"
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 10s

  # No Docker socket. The only service that can read SSH private keys.
  execution-ssh-broker:
    profiles: ["execution-ssh"]
    image: ${EXECUTION_BROKER_IMAGE:-afsharidevops/hermes-execution-broker:0.1.3}
    container_name: hermes-execution-ssh-broker
    restart: unless-stopped
    user: "${EXECUTION_RUN_AS:-10003:10003}"
    environment:
      BROKER_MODE: ssh
      BROKER_STATE: /state/capabilities.sqlite3
      BROKER_CONTROL_SECRET_FILE: /run/secrets/execution-control
      BROKER_APPROVAL_REQUEST_SECRET_FILE: /run/secrets/execution-approval-request
      BROKER_APPROVAL_PUBLIC_KEY_FILE: /run/secrets/execution-approval-public-key
      BROKER_SSH_PROFILE_INTEGRITY_SECRET_FILE: /run/secrets/execution-ssh-profile-integrity
      BROKER_APPROVER_URL: http://execution-approver:8751
      EXECUTION_FEATURES: ${EXECUTION_FEATURES:-}
      EXECUTION_FEATURES_FILE: /run/config/execution-features
      EXECUTION_POLICY_GENERATION: ${EXECUTION_POLICY_GENERATION:-0}
      EXECUTION_POLICY_GENERATION_FILE: /run/config/execution-policy-generation
    volumes:
      - ./data/stack-secrets/execution/ssh:/profiles:ro
      - ./data/stack-secrets/execution/ssh-state:/state
      - ./data/stack-secrets/execution/control-secret:/run/secrets/execution-control:ro
      - ./data/stack-secrets/execution/approval-request-secret:/run/secrets/execution-approval-request:ro
      - ./data/stack-secrets/execution/approval-public-key.pem:/run/secrets/execution-approval-public-key:ro
      - ./data/stack-secrets/execution/ssh-profile-integrity-secret:/run/secrets/execution-ssh-profile-integrity:ro
      - ./data/stack-secrets/execution/features:/run/config/execution-features:ro
      - ./data/stack-secrets/execution/policy-generation:/run/config/execution-policy-generation:ro
    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=0700,uid=10003,gid=10003
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    pids_limit: 128
    networks: [execution-control-net, execution-egress-net]
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8750/health', timeout=3)"
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 10s

  # Independent boundary: only this service has the dedicated approval bot token.
  # It can send exact summaries and sign grants, but has no Docker socket, SSH key,
  # Hermes control secret, or execution capability database.
  execution-approver:
    profiles: ["execution-approval"]
    image: ${EXECUTION_BROKER_IMAGE:-afsharidevops/hermes-execution-broker:0.1.3}
    container_name: hermes-execution-approver
    restart: unless-stopped
    user: "${EXECUTION_RUN_AS:-10003:10003}"
    environment:
      BROKER_MODE: approver
      BROKER_PORT: "8751"
      BROKER_STATE: /state/approvals.sqlite3
      BROKER_APPROVAL_REQUEST_SECRET_FILE: /run/secrets/execution-approval-request
      APPROVER_SIGNING_KEY_FILE: /run/secrets/execution-approval-signing-key
      EXECUTION_APPROVAL_BOT_TOKEN_FILE: /run/secrets/execution-approval-bot-token
      EXECUTION_APPROVAL_USERS_FILE: /run/secrets/execution-approval-users
      EXECUTION_POLICY_GENERATION: ${EXECUTION_POLICY_GENERATION:-0}
      EXECUTION_POLICY_GENERATION_FILE: /run/config/execution-policy-generation
    volumes:
      - ./data/stack-secrets/execution/approver-state:/state
      - ./data/stack-secrets/execution/approval-request-secret:/run/secrets/execution-approval-request:ro
      - ./data/stack-secrets/execution/approval-signing-key.pem:/run/secrets/execution-approval-signing-key:ro
      - ./data/stack-secrets/execution/approval-bot-token:/run/secrets/execution-approval-bot-token:ro
      - ./data/stack-secrets/execution/users:/run/secrets/execution-approval-users:ro
      - ./data/stack-secrets/execution/policy-generation:/run/config/execution-policy-generation:ro
    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=0700,uid=10003,gid=10003
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    pids_limit: 128
    networks: [execution-control-net, execution-approval-egress-net]
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8751/health', timeout=3)"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 10s


  # Optional browser-facing execution administration boundary. Operations Center
  # calls this service directly with a separate admin key; Smart Router never
  # receives that key, the approval signing key, Docker socket, or SSH secrets.
  execution-admin:
    profiles: ["execution-admin"]
    image: ${EXECUTION_BROKER_IMAGE:-afsharidevops/hermes-execution-broker:0.1.3}
    container_name: hermes-execution-admin
    restart: unless-stopped
    user: "${EXECUTION_RUN_AS:-10003:10003}"
    ports:
      - "${EXECUTION_ADMIN_BIND_IP:-127.0.0.1}:${EXECUTION_ADMIN_PORT:-8752}:8752"
    environment:
      BROKER_MODE: admin
      BROKER_PORT: "8752"
      BROKER_STATE: /state/admin.sqlite3
      EXECUTION_ADMIN_KEY_FILE: /run/secrets/execution-admin-key
      EXECUTION_FEATURES_FILE: /run/config/execution-features
      EXECUTION_POLICY_GENERATION_FILE: /run/config/execution-policy-generation
      EXECUTION_APPROVAL_USERS_FILE: /run/config/execution-users
      EXECUTION_ALLOWED_USERS_FILE: /run/config/telegram-allowed-users
      EXECUTION_HERMES_BOT_TOKEN_HASH_FILE: /run/config/hermes-bot-token.sha256
      EXECUTION_APPROVAL_BOT_TOKEN_FILE: /run/secrets/execution-approval-bot-token
      BROKER_CONTROL_SECRET_FILE: /run/config/execution-control
      EXECUTION_SSH_PROFILES_DIR: /profiles
      EXECUTION_ADMIN_AUDIT_FILE: /state/admin-audit.jsonl
      EXECUTION_ADMIN_ALLOWED_ORIGINS: ${EXECUTION_ADMIN_ALLOWED_ORIGINS:-http://127.0.0.1:8787,http://localhost:8787}
      EXECUTION_APPROVER_URL: http://execution-approver:8751
      EXECUTION_DOCKER_BROKER_URL: http://execution-docker-broker:8750
      EXECUTION_SSH_BROKER_URL: http://execution-ssh-broker:8750
    volumes:
      - ./data/stack-secrets/execution/admin-state:/state
      - ./data/stack-secrets/execution/admin-key:/run/secrets/execution-admin-key:ro
      - ./data/stack-secrets/execution/features:/run/config/execution-features
      - ./data/stack-secrets/execution/policy-generation:/run/config/execution-policy-generation
      - ./data/stack-secrets/execution/users:/run/config/execution-users
      - ./data/stack-secrets/execution/telegram-allowed-users:/run/config/telegram-allowed-users:ro
      - ./data/stack-secrets/execution/hermes-bot-token.sha256:/run/config/hermes-bot-token.sha256:ro
      - ./data/stack-secrets/execution/approval-bot-token:/run/secrets/execution-approval-bot-token
      - ./data/stack-secrets/execution/control-secret:/run/config/execution-control
      - ./data/stack-secrets/execution/ssh:/profiles:ro
    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=0700,uid=10003,gid=10003
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    pids_limit: 64
    networks:
      - execution-control-net
      - execution-admin-ingress-net
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8752/health', timeout=3)"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 10s

  open-webui:
    profiles: ["open-webui"]
    image: ${OPENWEBUI_IMAGE_REPOSITORY:-ghcr.io/open-webui/open-webui}:${OPENWEBUI_IMAGE_TAG:-main}
    container_name: open-webui
    restart: unless-stopped
    ports:
      - "${OPENWEBUI_BIND_IP:-127.0.0.1}:${OPENWEBUI_PORT:-3000}:8080"
    volumes:
      - ./data/open-webui:/app/backend/data
    environment:
      WEBUI_URL: ${OPENWEBUI_URL:-http://localhost:3000}
      WEBUI_SECRET_KEY: ${OPENWEBUI_SECRET_KEY}
      ENABLE_SIGNUP: ${OPENWEBUI_ENABLE_SIGNUP:-false}
      ENABLE_OPENAI_API: "true"
      OPENAI_API_BASE_URL: ${OPENWEBUI_OPENAI_BASE_URL}
      OPENAI_API_KEY: ${OPENWEBUI_OPENAI_API_KEY}
      ENABLE_OLLAMA_API: "false"
      # Object storage: "local" keeps uploads under data/open-webui; "s3" sends
      # them to the shared S3 endpoint (the bundled RustFS server or an
      # external provider). ./manage.sh s3-enable flips this together with the
      # credentials below, and docs/S3-STORAGE.md explains the trade-offs.
      STORAGE_PROVIDER: ${OPENWEBUI_STORAGE_PROVIDER:-local}
      S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
      S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
      S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
      S3_BUCKET_NAME: ${S3_BUCKET:-locallab}
      S3_REGION_NAME: ${S3_REGION:-us-east-1}
      S3_KEY_PREFIX: ${S3_KEY_PREFIX:-}
      # "path" matches RustFS, MinIO, and most self-hosted endpoints; set
      # OPENWEBUI_S3_ADDRESSING_STYLE=virtual for providers that require
      # virtual-hosted-style buckets.
      S3_ADDRESSING_STYLE: ${OPENWEBUI_S3_ADDRESSING_STYLE:-path}
      S3_ENABLE_TAGGING: "false"
      S3_USE_ACCELERATE_ENDPOINT: "false"
    extra_hosts:
      - "host.docker.internal:host-gateway"
    networks: [agent-net]

  n8n-init:
    profiles: ["n8n"]
    image: ${N8N_IMAGE_REPOSITORY:-n8nio/n8n}:${N8N_IMAGE_TAG:-latest}
    container_name: hermes-n8n-init
    user: "0:0"
    entrypoint: ["/bin/sh", "-c"]
    command: ["install -d -m 0700 -o 1000 -g 1000 /home/node/.n8n"]
    volumes:
      - ./data/n8n:/home/node/.n8n
    cap_drop: ["ALL"]
    cap_add: ["CHOWN", "DAC_OVERRIDE", "FOWNER"]
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  n8n:
    profiles: ["n8n"]
    image: ${N8N_IMAGE_REPOSITORY:-n8nio/n8n}:${N8N_IMAGE_TAG:-latest}
    container_name: hermes-n8n
    restart: unless-stopped
    ports:
      - "${N8N_BIND_IP:-127.0.0.1}:${N8N_PORT:-5678}:5678"
    volumes:
      - ./data/n8n:/home/node/.n8n
    environment:
      N8N_HOST: ${N8N_HOSTNAME:-localhost}
      N8N_PORT: "5678"
      N8N_PROTOCOL: ${N8N_PROTOCOL:-http}
      N8N_LISTEN_ADDRESS: 0.0.0.0
      N8N_EDITOR_BASE_URL: ${N8N_PUBLIC_URL:-http://localhost:5678}
      WEBHOOK_URL: ${N8N_PUBLIC_URL:-http://localhost:5678}
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE:-false}
      N8N_PROXY_HOPS: ${N8N_PROXY_HOPS:-0}
      N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
      N8N_BLOCK_ENV_ACCESS_IN_NODE: "true"
      N8N_DIAGNOSTICS_ENABLED: ${N8N_DIAGNOSTICS_ENABLED:-false}
      N8N_VERSION_NOTIFICATIONS_ENABLED: ${N8N_VERSION_NOTIFICATIONS_ENABLED:-false}
      N8N_PERSONALIZATION_SURVEY_ENABLED: "false"
      GENERIC_TIMEZONE: ${N8N_TIMEZONE:-UTC}
      TZ: ${N8N_TIMEZONE:-UTC}
      NODE_ENV: production
    depends_on:
      n8n-init:
        condition: service_completed_successfully
    extra_hosts:
      - "host.docker.internal:host-gateway"
    networks: [agent-net]
    healthcheck:
      test:
        - CMD
        - node
        - -e
        - "fetch('http://127.0.0.1:5678/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 40s

  caddy:
    profiles: ["caddy"]
    image: ${CADDY_IMAGE:-caddy:2-alpine}
    container_name: hermes-stack-caddy
    restart: unless-stopped
    ports:
      - "${CADDY_BIND_IP:-0.0.0.0}:80:80"
      - "${CADDY_BIND_IP:-0.0.0.0}:443:443"
      - "${CADDY_BIND_IP:-0.0.0.0}:443:443/udp"
    volumes:
      - ./data/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - ./data/caddy/data:/data
      - ./data/caddy/config:/config
    networks: [agent-net]

  content-manager-runtime-init:
    profiles: ["content", "media", "panel"]
    image: alpine:3.22
    container_name: content-manager-runtime-init
    user: '0:0'
    command:
      - sh
      - -c
      - |
        set -e
        run_as="$${PANEL_RUN_AS:-1000:1000}"
        uid="$${run_as%%:*}"
        gid="$${run_as#*:}"
        mkdir -p /runtime-config /runtime-locks
        chown "$${uid}:$${gid}" /runtime-config
        chmod 0755 /runtime-config
        touch /runtime-locks/runtime-config.lock
        chmod 1777 /runtime-locks
        chmod 0666 /runtime-locks/runtime-config.lock
    volumes:
      - ./data/content-manager/runtime:/runtime-config
      - ./data/content-manager/runtime-locks:/runtime-locks
    cap_add:
      - CHOWN
      - DAC_OVERRIDE
      - FOWNER
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  content-postgres:
    profiles: ["content", "media", "panel"]
    image: postgres:18.6
    container_name: content-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${CONTENT_MANAGER_POSTGRES_DB:-content_manager}
      POSTGRES_USER: ${CONTENT_MANAGER_POSTGRES_USER:-content_manager}
      POSTGRES_PASSWORD: ${CONTENT_MANAGER_POSTGRES_PASSWORD:-content_manager}
      CONTENT_MANAGER_POSTGRES_APP_USER: ${CONTENT_MANAGER_POSTGRES_APP_USER:-content_manager_app}
      CONTENT_MANAGER_POSTGRES_APP_PASSWORD: ${CONTENT_MANAGER_POSTGRES_APP_PASSWORD:-content_manager_app}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10
    volumes:
      - ./data/content-manager/postgres:/var/lib/postgresql
      - ./deploy/postgres/content-manager-init.sh:/docker-entrypoint-initdb.d/10-content-manager-init.sh:ro
    networks: [agent-net]

  content-manager-json-migrate:
    profiles: ["content", "media", "panel"]
    image: ${PANEL_IMAGE_REPOSITORY:-afsharidevops/content-panel}:${PANEL_IMAGE_TAG:-0.6.0}
    build:
      context: .
      dockerfile: panel/Dockerfile
    container_name: content-manager-json-migrate
    restart: "no"
    user: "${PANEL_RUN_AS:-1000:1000}"
    depends_on:
      content-postgres:
        condition: service_healthy
    entrypoint: ["python3", "-m", "content_pipeline.db"]
    command: ["migrate-json", "--root", "/stack"]
    environment:
      CONTENT_MANAGER_DATABASE_URL: ${CONTENT_MANAGER_DATABASE_URL:-postgresql+psycopg://content_manager_app:content_manager_app@content-postgres:5432/content_manager}
      TZ: ${N8N_TIMEZONE:-Asia/Tehran}
    volumes:
      - ./data:/stack/data:ro
    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=1777
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  # Telegram editorial bot: long-polls the Bot API (no public ingress needed),
  # turns operator links into drafts with Approve/Reject buttons, and publishes
  # approved posts to the configured channel. The image is built on GitHub
  # Actions and published to Docker Hub; policy and sources are mounted
  # read-only from the editorial working copy.
  content-bot:
    profiles: ["content"]
    image: ${CONTENT_BOT_IMAGE_REPOSITORY:-afsharidevops/content-bot}:${CONTENT_BOT_IMAGE_TAG:-0.4.3}
    container_name: content-bot
    restart: unless-stopped
    user: "${CONTENT_BOT_RUN_AS:-10004:10004}"
    depends_on:
      content-manager-runtime-init:
        condition: service_completed_successfully
      content-postgres:
        condition: service_healthy
      content-manager-json-migrate:
        condition: service_completed_successfully
    environment:
      CONTENT_BOT_TOKEN: ${CONTENT_BOT_TOKEN:-}
      CONTENT_TELEGRAM_CHANNEL: ${CONTENT_TELEGRAM_CHANNEL:-}
      CONTENT_TELEGRAM_USERS: ${CONTENT_TELEGRAM_USERS:-}
      CONTENT_BALE_USERS: ${CONTENT_BALE_USERS:-}
      BOT_SHOW_ALL_SUPPORTED_PROVIDERS: ${BOT_SHOW_ALL_SUPPORTED_PROVIDERS:-true}
      CONTENT_TELEGRAM_API_BASE: ${CONTENT_TELEGRAM_API_BASE:-https://api.telegram.org}
      CONTENT_WRITER_BASE_URL: ${CONTENT_WRITER_BASE_URL:-}
      CONTENT_WRITER_API_KEY: ${CONTENT_WRITER_API_KEY:-}
      CONTENT_WRITER_MODEL: ${CONTENT_WRITER_MODEL:-auto-strong}
      CONTENT_WRITER_MAX_TOKENS: ${CONTENT_WRITER_MAX_TOKENS:-4000}
      CONTENT_WRITER_REASONING_EFFORT: ${CONTENT_WRITER_REASONING_EFFORT:-}
      CONTENT_SCHEDULER_ENABLED: ${CONTENT_SCHEDULER_ENABLED:-true}
      CONTENT_DAILY_JOB_RUNNING_TTL_SECONDS: ${CONTENT_DAILY_JOB_RUNNING_TTL_SECONDS:-1800}
      CONTENT_MEDIA_STUDIO_URL: ${CONTENT_MEDIA_STUDIO_URL:-http://media-studio:8850}
      CONTENT_MEDIA_STUDIO_TOKEN: ${CONTENT_MEDIA_STUDIO_TOKEN:-}
      CONTENT_MEDIA_STUDIO_CONTEXT_SIGNING_KEY: ${CONTENT_MEDIA_STUDIO_CONTEXT_SIGNING_KEY:-}
      CONTENT_MEDIA_IMAGE_DRIVER: ${CONTENT_MEDIA_IMAGE_DRIVER:-api-image}
      CONTENT_MEDIA_VIDEO_DRIVER: ${CONTENT_MEDIA_VIDEO_DRIVER:-flow-video}
      CONTENT_MEDIA_VIDEO_EDIT_DRIVER: ${CONTENT_MEDIA_VIDEO_EDIT_DRIVER:-video-edit}
      CONTENT_MEDIA_JOB_TIMEOUT_SECONDS: ${CONTENT_MEDIA_JOB_TIMEOUT_SECONDS:-1200}
      CONTENT_NOTEBOOKLM_URL: ${CONTENT_NOTEBOOKLM_URL:-http://notebooklm-worker:8860}
      CONTENT_NOTEBOOKLM_TOKEN: ${NOTEBOOKLM_API_TOKEN:-}
      CONTENT_NOTEBOOKLM_ENABLED: ${CONTENT_NOTEBOOKLM_ENABLED:-true}
      CONTENT_NOTEBOOKLM_TIMEOUT: ${CONTENT_NOTEBOOKLM_TIMEOUT:-1800}
      CONTENT_NOTEBOOKLM_DEFAULT_PROFILE: ${CONTENT_NOTEBOOKLM_DEFAULT_PROFILE:-technical_fa}
      CONTENT_SEARCH_ENABLED: ${CONTENT_SEARCH_ENABLED:-true}
      CONTENT_TOPIC_DRAFTS_ENABLED: ${CONTENT_TOPIC_DRAFTS_ENABLED:-true}
      CONTENT_SEARCH_MAX_RESULTS: ${CONTENT_SEARCH_MAX_RESULTS:-5}
      CONTENT_SEARCH_TIMEOUT: ${CONTENT_SEARCH_TIMEOUT:-25}
      CONTENT_VIDEO_CHARACTER: ${CONTENT_VIDEO_CHARACTER:-}
      CONTENT_VIDEO_CHARACTER_PROMPT: ${CONTENT_VIDEO_CHARACTER_PROMPT:-}
      CONTENT_VIDEO_ASPECT: ${CONTENT_VIDEO_ASPECT:-9:16 vertical}
      CONTENT_VIDEO_SEGMENT_SECONDS: ${CONTENT_VIDEO_SEGMENT_SECONDS:-10}
      CONTENT_PLATFORMS_ENABLED: ${CONTENT_PLATFORMS_ENABLED:-false}
      CONTENT_BALE_TOKEN: ${CONTENT_BALE_TOKEN:-}
      CONTENT_BALE_CHAT_ID: ${CONTENT_BALE_CHAT_ID:-}
      CONTENT_BALE_API_BASE: ${CONTENT_BALE_API_BASE:-https://tapi.bale.ai}
      CONTENT_EITAA_TOKEN: ${CONTENT_EITAA_TOKEN:-}
      CONTENT_EITAA_CHAT_ID: ${CONTENT_EITAA_CHAT_ID:-}
      CONTENT_EITAA_API_BASE: ${CONTENT_EITAA_API_BASE:-https://eitaayar.ir/api}
      CONTENT_APARAT_TOKEN: ${CONTENT_APARAT_TOKEN:-}
      CONTENT_APARAT_COOKIE: ${CONTENT_APARAT_COOKIE:-}
      CONTENT_APARAT_LABEL: ${CONTENT_APARAT_LABEL:-}
      CONTENT_APARAT_CATEGORY: ${CONTENT_APARAT_CATEGORY:-10}
      CONTENT_APARAT_TAGS: ${CONTENT_APARAT_TAGS:-technology,video,tutorial}
      CONTENT_APARAT_WATERMARK: ${CONTENT_APARAT_WATERMARK:-1}
      CONTENT_APARAT_VIDEO_PASS: ${CONTENT_APARAT_VIDEO_PASS:-0}
      CONTENT_APARAT_API_BASE: ${CONTENT_APARAT_API_BASE:-https://www.aparat.com}
      CONTENT_APARAT_TIMEOUT: ${CONTENT_APARAT_TIMEOUT:-120}
      CONTENT_APARAT_CHUNK_BYTES: ${CONTENT_APARAT_CHUNK_BYTES:-3145728}
      CONTENT_LINKEDIN_ACCESS_TOKEN: ${CONTENT_LINKEDIN_ACCESS_TOKEN:-}
      CONTENT_LINKEDIN_ACCOUNT: ${CONTENT_LINKEDIN_ACCOUNT:-personal}
      CONTENT_LINKEDIN_ACCOUNT_TYPE: ${CONTENT_LINKEDIN_ACCOUNT_TYPE:-person}
      CONTENT_LINKEDIN_PERSON_ID: ${CONTENT_LINKEDIN_PERSON_ID:-}
      CONTENT_LINKEDIN_ORGANIZATION_ID: ${CONTENT_LINKEDIN_ORGANIZATION_ID:-}
      CONTENT_LINKEDIN_AUTHOR_URN: ${CONTENT_LINKEDIN_AUTHOR_URN:-}
      CONTENT_LINKEDIN_LABEL: ${CONTENT_LINKEDIN_LABEL:-}
      CONTENT_LINKEDIN_API_BASE: ${CONTENT_LINKEDIN_API_BASE:-https://api.linkedin.com}
      CONTENT_LINKEDIN_API_VERSION: ${CONTENT_LINKEDIN_API_VERSION:-202601}
      CONTENT_LINKEDIN_TIMEOUT: ${CONTENT_LINKEDIN_TIMEOUT:-60}
      CONTENT_LINKEDIN_RETRIES: ${CONTENT_LINKEDIN_RETRIES:-3}
      CONTENT_SOCIAL_ACCOUNTS_FILE: ${CONTENT_SOCIAL_ACCOUNTS_FILE:-social-accounts.yaml}
      CONTENT_ADAPT_ENABLED: ${CONTENT_ADAPT_ENABLED:-true}
      CONTENT_PROMPT_DIR: ${CONTENT_PROMPT_DIR:-}
      CONTENT_RUNTIME_CONFIG_PATH: /runtime-config/runtime-config.json
      CONTENT_RUNTIME_LOCK_PATH: /runtime-locks/runtime-config.lock
      CONTENT_MANAGER_DATABASE_URL: ${CONTENT_MANAGER_DATABASE_URL:-postgresql+psycopg://content_manager_app:content_manager_app@content-postgres:5432/content_manager}
      INSTAGRAM_BUSINESS_ID: ${INSTAGRAM_BUSINESS_ID:-}
      INSTAGRAM_ACCESS_TOKEN: ${INSTAGRAM_ACCESS_TOKEN:-}
      INSTAGRAM_APP_ID: ${INSTAGRAM_APP_ID:-}
      INSTAGRAM_APP_SECRET: ${INSTAGRAM_APP_SECRET:-}
      INSTAGRAM_MEDIA_PUBLIC_BASE_URL: ${INSTAGRAM_MEDIA_PUBLIC_BASE_URL:-}
      INSTAGRAM_API_BASE: ${INSTAGRAM_API_BASE:-https://graph.facebook.com}
      INSTAGRAM_API_VERSION: ${INSTAGRAM_API_VERSION:-v26.0}
      INSTAGRAM_POLL_TIMEOUT_SECONDS: ${INSTAGRAM_POLL_TIMEOUT_SECONDS:-600}
      INSTAGRAM_DISABLE_REFRESH: ${INSTAGRAM_DISABLE_REFRESH:-false}
      INSTAGRAM_AUTO_PUBLISH: ${INSTAGRAM_AUTO_PUBLISH:-true}
      CONTENT_POLICY_DIR: /policy
      CONTENT_DATA_DIR: /data
      TZ: ${N8N_TIMEZONE:-Asia/Tehran}
    volumes:
      - ./data/content-manager/config:/policy:ro
      - ./data/content-manager/runtime:/runtime-config:ro
      - ./data/content-manager/runtime-locks:/runtime-locks
      - ./data/content-bot:/data
    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=1777
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    sysctls:
      # The bot reaches Telegram and other public APIs over IPv4 only. The
      # kernel has no IPv6 route to them, so leave the duplicate stack off
      # instead of letting a lookup attempt fail with a misleading
      # "Network is unreachable" after the IPv4 path recovers.
      net.ipv6.conf.all.disable_ipv6: "1"
    networks: [agent-net]

  # Content Bot daily worker: consumes durable daily suggestion jobs outside
  # the polling process so feed discovery, filtering, and draft generation do
  # not reduce Telegram/Bale command responsiveness.
  content-bot-daily-worker:
    profiles: ["content"]
    image: ${CONTENT_BOT_IMAGE_REPOSITORY:-afsharidevops/content-bot}:${CONTENT_BOT_IMAGE_TAG:-0.4.3}
    container_name: content-bot-daily-worker
    restart: unless-stopped
    user: "${CONTENT_BOT_RUN_AS:-10004:10004}"
    depends_on:
      content-bot:
        condition: service_started
    command: ["python", "-m", "content_bot.daily_worker"]
    environment:
      # The worker needs only the writer endpoint, tenant policy/data paths,
      # and runtime config. No bot token or inbound allowlists are required.
      CONTENT_WRITER_BASE_URL: ${CONTENT_WRITER_BASE_URL:-}
      CONTENT_WRITER_API_KEY: ${CONTENT_WRITER_API_KEY:-}
      CONTENT_WRITER_MODEL: ${CONTENT_WRITER_MODEL:-auto-strong}
      CONTENT_WRITER_MAX_TOKENS: ${CONTENT_WRITER_MAX_TOKENS:-4000}
      CONTENT_WRITER_REASONING_EFFORT: ${CONTENT_WRITER_REASONING_EFFORT:-}
      CONTENT_POLICY_DIR: /policy
      CONTENT_DATA_DIR: /data
      CONTENT_DAILY_JOB_RUNNING_TTL_SECONDS: ${CONTENT_DAILY_JOB_RUNNING_TTL_SECONDS:-1800}
      CONTENT_RUNTIME_CONFIG_PATH: /runtime-config/runtime-config.json
      CONTENT_RUNTIME_LOCK_PATH: /runtime-locks/runtime-config.lock
      CONTENT_MANAGER_DATABASE_URL: ${CONTENT_MANAGER_DATABASE_URL:-postgresql+psycopg://content_manager_app:content_manager_app@content-postgres:5432/content_manager}
      TZ: ${N8N_TIMEZONE:-Asia/Tehran}
    volumes:
      - ./data/content-manager/config:/policy:ro
      - ./data/content-manager/runtime:/runtime-config:ro
      - ./data/content-manager/runtime-locks:/runtime-locks
      - ./data/content-bot:/data
    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=1777
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  # Media Studio: optional media generation worker (compose profile "media").
  # Submits jobs to browser drivers (Google Flow video, Gemini images) or to an
  # OpenAI-compatible image API. Google drivers need a signed-in session; the
  # api-image driver works with any compatible gateway. The image is built on
  # GitHub Actions and published to Docker Hub.
  media-studio:
    profiles: ["media"]
    image: ${MEDIA_STUDIO_IMAGE_REPOSITORY:-afsharidevops/media-studio}:${MEDIA_STUDIO_IMAGE_TAG:-0.5.1}
    container_name: media-studio
    restart: unless-stopped
    user: "${MEDIA_STUDIO_RUN_AS:-10005:10005}"
    depends_on:
      content-manager-runtime-init:
        condition: service_completed_successfully
      content-postgres:
        condition: service_healthy
      content-manager-json-migrate:
        condition: service_completed_successfully
    environment:
      MEDIA_STUDIO_DATA_DIR: /data
      # The API always listens on every container interface so the rest of the
      # stack can reach it; MEDIA_STUDIO_BIND_IP controls the host port only.
      MEDIA_STUDIO_BIND_IP: 0.0.0.0
      MEDIA_STUDIO_DRIVERS: ${MEDIA_STUDIO_DRIVERS:-api-image,api-video,flow-video,google-video-auto,video-edit,timeline-video,motion-infographic}
      MEDIA_STUDIO_API_TOKEN: ${MEDIA_STUDIO_API_TOKEN:-}
      MEDIA_STUDIO_CONTEXT_SIGNING_KEY: ${CONTENT_MEDIA_STUDIO_CONTEXT_SIGNING_KEY:-}
      MEDIA_STUDIO_WRITER_BASE_URL: ${MEDIA_STUDIO_WRITER_BASE_URL:-}
      MEDIA_STUDIO_WRITER_API_KEY: ${MEDIA_STUDIO_WRITER_API_KEY:-}
      MEDIA_STUDIO_WRITER_MODEL: ${MEDIA_STUDIO_WRITER_MODEL:-auto}
      MEDIA_STUDIO_VIDEO_BASE_URL: ${MEDIA_STUDIO_VIDEO_BASE_URL:-}
      MEDIA_STUDIO_VIDEO_API_KEY: ${MEDIA_STUDIO_VIDEO_API_KEY:-}
      MEDIA_STUDIO_VIDEO_MODEL: ${MEDIA_STUDIO_VIDEO_MODEL:-}
      MEDIA_STUDIO_VIDEO_PROVIDER: ${MEDIA_STUDIO_VIDEO_PROVIDER:-}
      MEDIA_STUDIO_VIDEO_DURATION: ${MEDIA_STUDIO_VIDEO_DURATION:-}
      MEDIA_STUDIO_VIDEO_ASPECT_RATIO: ${MEDIA_STUDIO_VIDEO_ASPECT_RATIO:-}
      MEDIA_STUDIO_VIDEO_RESOLUTION: ${MEDIA_STUDIO_VIDEO_RESOLUTION:-}
      MEDIA_STUDIO_VIDEO_POLL_SECONDS: ${MEDIA_STUDIO_VIDEO_POLL_SECONDS:-10}
      MEDIA_STUDIO_VIDEO_TIMEOUT_SECONDS: ${MEDIA_STUDIO_VIDEO_TIMEOUT_SECONDS:-900}
      MEDIA_STUDIO_IMAGE_SIZE: ${MEDIA_STUDIO_IMAGE_SIZE:-1024x1024}
      MEDIA_STUDIO_BRAND_LABEL: ${MEDIA_STUDIO_BRAND_LABEL:-Locallab}
      MEDIA_STUDIO_BRAND_POSITION: ${MEDIA_STUDIO_BRAND_POSITION:-bottom-right}
      MEDIA_STUDIO_BRAND_STYLE: ${MEDIA_STUDIO_BRAND_STYLE:-aurora}
      MEDIA_STUDIO_SESSION_MODE: ${MEDIA_STUDIO_SESSION_MODE:-persistent}
      MEDIA_STUDIO_CDP_URL: ${MEDIA_STUDIO_CDP_URL:-http://host.docker.internal:9222}
      MEDIA_STUDIO_HEADLESS: ${MEDIA_STUDIO_HEADLESS:-true}
      MEDIA_STUDIO_EXTENSION_PATH: ${MEDIA_STUDIO_EXTENSION_PATH:-/app/extensions/locallab-flow-unlock}
      MEDIA_STUDIO_BLOCK_GEO_REDIRECT: ${MEDIA_STUDIO_BLOCK_GEO_REDIRECT:-true}
      MEDIA_STUDIO_FREEZE_ON_READY: ${MEDIA_STUDIO_FREEZE_ON_READY:-true}
      MEDIA_STUDIO_FLOW_VIDEO_MODEL: ${MEDIA_STUDIO_FLOW_VIDEO_MODEL:-Veo 3.1 - Fast}
      MEDIA_STUDIO_FLOW_VIDEO_ASPECT_RATIO: ${MEDIA_STUDIO_FLOW_VIDEO_ASPECT_RATIO:-16:9}
      MEDIA_STUDIO_FLOW_VIDEO_OUTPUT_COUNT: ${MEDIA_STUDIO_FLOW_VIDEO_OUTPUT_COUNT:-x1}
      MEDIA_STUDIO_FLOW_CONFIRM_BEFORE_GENERATING: ${MEDIA_STUDIO_FLOW_CONFIRM_BEFORE_GENERATING:-Never}
      MEDIA_STUDIO_STEP_TIMEOUT_SECONDS: ${MEDIA_STUDIO_STEP_TIMEOUT_SECONDS:-30}
      MEDIA_STUDIO_JOB_TIMEOUT_SECONDS: ${MEDIA_STUDIO_JOB_TIMEOUT_SECONDS:-900}
      MEDIA_STUDIO_VIDEO_EDIT_MAX_SIDE: ${MEDIA_STUDIO_VIDEO_EDIT_MAX_SIDE:-1920}
      MEDIA_STUDIO_VIDEO_EDIT_MAX_SECONDS: ${MEDIA_STUDIO_VIDEO_EDIT_MAX_SECONDS:-0}
      MEDIA_STUDIO_VIDEO_EDIT_TIMEOUT_SECONDS: ${MEDIA_STUDIO_VIDEO_EDIT_TIMEOUT_SECONDS:-900}
      MEDIA_STUDIO_UPLOAD_TTL_SECONDS: ${MEDIA_STUDIO_UPLOAD_TTL_SECONDS:-86400}
      MEDIA_STUDIO_FFMPEG: ${MEDIA_STUDIO_FFMPEG:-}
      MEDIA_STUDIO_TIMEZONE: ${MEDIA_STUDIO_TIMEZONE:-Asia/Tehran}
      MEDIA_STUDIO_RUNTIME_CONFIG_PATH: /runtime-config/runtime-config.json
      MEDIA_STUDIO_RUNTIME_LOCK_PATH: /runtime-locks/runtime-config.lock
      S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
      S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
      S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
      S3_BUCKET: ${S3_BUCKET:-content-manager-storage}
      S3_ISOLATION_MODE: ${S3_ISOLATION_MODE:-shared_bucket_tenant_prefixes}
      CONTENT_MANAGER_DATABASE_URL: ${CONTENT_MANAGER_DATABASE_URL:-postgresql+psycopg://content_manager_app:content_manager_app@content-postgres:5432/content_manager}
      TZ: ${N8N_TIMEZONE:-Asia/Tehran}
    ports:
      - "${MEDIA_STUDIO_BIND_IP:-127.0.0.1}:${MEDIA_STUDIO_PORT:-8850}:8850"
    volumes:
      - ./data/content-manager/config:/policy:ro
      - ./data/content-manager/runtime:/runtime-config:ro
      - ./data/content-manager/runtime-locks:/runtime-locks
      - ./data/media-studio:/data
    extra_hosts:
      - "host.docker.internal:host-gateway"
    shm_size: 1gb
    init: true
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  # NotebookLM Worker (compose profile "notebooklm"): an independent video
  # provider that drives Google NotebookLM Video Overviews through a real
  # browser session, so a NotebookLM subscription replaces a paid video API.
  # The session mode (NOTEBOOKLM_SESSION_MODE):
  #
  # - "persistent" (default): runs Chromium inside the container with a
  #   browser profile stored in data/notebooklm-worker. The session is
  #   imported once through the Content Console (NotebookLM view).
  #
  # - "cdp": attaches to an external Chrome on the Docker host via the
  #   DevTools protocol (NOTEBOOKLM_CDP_URL, default host.docker.internal:9222).
  #
  # Google blocks automated sign-in, so the worker never stores a password.
  # The image is
  # built on GitHub Actions and published to Docker Hub.
  notebooklm-worker:
    profiles: ["notebooklm"]
    image: ${NOTEBOOKLM_WORKER_IMAGE_REPOSITORY:-afsharidevops/notebooklm-worker}:${NOTEBOOKLM_WORKER_IMAGE_TAG:-0.1.0}
    container_name: notebooklm-worker
    restart: unless-stopped
    user: "${NOTEBOOKLM_RUN_AS:-10006:10006}"
    environment:
      NOTEBOOKLM_DATA_DIR: /data
      # The API always listens on every container interface so the rest of the
      # stack can reach it; NOTEBOOKLM_BIND_IP controls the host port only.
      NOTEBOOKLM_BIND_IP: 0.0.0.0
      NOTEBOOKLM_PORT: "8860"
      NOTEBOOKLM_API_TOKEN: ${NOTEBOOKLM_API_TOKEN:-}
      NOTEBOOKLM_ENABLED: ${CONTENT_NOTEBOOKLM_ENABLED:-true}
      NOTEBOOKLM_SESSION_MODE: ${NOTEBOOKLM_SESSION_MODE:-persistent}
      NOTEBOOKLM_CDP_URL: ${NOTEBOOKLM_CDP_URL:-http://host.docker.internal:9222}
      NOTEBOOKLM_HEADLESS: ${NOTEBOOKLM_HEADLESS:-true}
      NOTEBOOKLM_BROWSER_PROFILE: ${CONTENT_NOTEBOOKLM_BROWSER_PROFILE:-/data/notebooklm-browser-profile}
      NOTEBOOKLM_TIMEOUT: ${CONTENT_NOTEBOOKLM_TIMEOUT:-1800}
      NOTEBOOKLM_STEP_TIMEOUT_SECONDS: ${NOTEBOOKLM_STEP_TIMEOUT_SECONDS:-45}
      NOTEBOOKLM_SOURCE_TIMEOUT_SECONDS: ${NOTEBOOKLM_SOURCE_TIMEOUT_SECONDS:-300}
      NOTEBOOKLM_VIDEO_TIMEOUT_SECONDS: ${NOTEBOOKLM_VIDEO_TIMEOUT_SECONDS:-1500}
      NOTEBOOKLM_DOWNLOAD_TIMEOUT_SECONDS: ${NOTEBOOKLM_DOWNLOAD_TIMEOUT_SECONDS:-300}
      NOTEBOOKLM_POLL_SECONDS: ${NOTEBOOKLM_POLL_SECONDS:-15}
      NOTEBOOKLM_DEFAULT_PROFILE: ${CONTENT_NOTEBOOKLM_DEFAULT_PROFILE:-technical_fa}
      NOTEBOOKLM_LOCALE: ${NOTEBOOKLM_LOCALE:-fa-IR}
      NOTEBOOKLM_HOME_URL: ${NOTEBOOKLM_HOME_URL:-https://notebooklm.google.com/}
      NOTEBOOKLM_KEEP_SCREENSHOTS: ${NOTEBOOKLM_KEEP_SCREENSHOTS:-true}
      NOTEBOOKLM_UPLOAD_TTL_SECONDS: ${NOTEBOOKLM_UPLOAD_TTL_SECONDS:-86400}
      NOTEBOOKLM_LOG_LEVEL: ${NOTEBOOKLM_LOG_LEVEL:-INFO}
      NOTEBOOKLM_SELECTORS_FILE: ${NOTEBOOKLM_SELECTORS_FILE:-}
      NOTEBOOKLM_TIMEZONE: ${NOTEBOOKLM_TIMEZONE:-Asia/Tehran}
      NOTEBOOKLM_S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
      NOTEBOOKLM_S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
      NOTEBOOKLM_S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
      NOTEBOOKLM_S3_BUCKET: ${S3_BUCKET:-locallab}
      NOTEBOOKLM_S3_KEY_PREFIX: ${NOTEBOOKLM_S3_KEY_PREFIX:-videos/notebooklm/}
      NOTEBOOKLM_S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-true}
      TZ: ${N8N_TIMEZONE:-Asia/Tehran}
    ports:
      - "${NOTEBOOKLM_BIND_IP:-127.0.0.1}:${NOTEBOOKLM_PORT:-8860}:8860"
    volumes:
      - ./data/notebooklm-worker:/data
    extra_hosts:
      - "host.docker.internal:host-gateway"
    shm_size: 1gb
    init: true
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  # Operator panel (compose profile "panel"): a minimal console for stack
  # status, validated configuration editing, logs and a fixed whitelist of
  # actions. It is optional, stays bound to loopback unless PANEL_BIND_IP is
  # changed, and is not required by content-bot or media-studio. The repository
  # is mounted at the identical host path so `docker compose` resolves bind
  # mounts exactly like a host invocation.
  panel:
    profiles: ["panel"]
    image: ${PANEL_IMAGE_REPOSITORY:-afsharidevops/content-panel}:${PANEL_IMAGE_TAG:-0.6.0}
    build:
      context: .
      dockerfile: panel/Dockerfile
    container_name: content-panel
    restart: unless-stopped
    user: "${PANEL_RUN_AS:-1000:1000}"
    depends_on:
      content-manager-runtime-init:
        condition: service_completed_successfully
      content-postgres:
        condition: service_healthy
      content-manager-json-migrate:
        condition: service_completed_successfully
    environment:
      PANEL_ROOT: ${PANEL_STACK_PATH:-${PWD}}
      PANEL_BIND_IP: 0.0.0.0
      PANEL_PORT: "8899"
      PANEL_TOKEN_FILE: /run/panel/token
      PANEL_ACTIONS_ENABLED: ${PANEL_ACTIONS_ENABLED:-true}
      PANEL_COOKIE_SECURE: ${PANEL_COOKIE_SECURE:-false}
      PANEL_RUNTIME_CONFIG_PATH: /runtime-config/runtime-config.json
      PANEL_RUNTIME_LOCK_PATH: /runtime-locks/runtime-config.lock
      CONTENT_MEDIA_STUDIO_CONTEXT_SIGNING_KEY: ${CONTENT_MEDIA_STUDIO_CONTEXT_SIGNING_KEY:-}
      CONTENT_MANAGER_DATABASE_URL: ${CONTENT_MANAGER_DATABASE_URL:-postgresql+psycopg://content_manager_app:content_manager_app@content-postgres:5432/content_manager}
      TZ: ${N8N_TIMEZONE:-Asia/Tehran}
    ports:
      - "${PANEL_BIND_IP:-127.0.0.1}:${PANEL_PORT:-8899}:8899"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - "${PANEL_STACK_PATH:-${PWD}}:${PANEL_STACK_PATH:-${PWD}}"
      - ./data/content-manager/runtime:/runtime-config
      - ./data/content-manager/runtime-locks:/runtime-locks
      # Backups live next to the checkout (../<stack>-backups); mount the same
      # path into the panel so its Backups view can list and create archives.
      - "${PANEL_STACK_PATH:-${PWD}}-backups:${PANEL_STACK_PATH:-${PWD}}-backups"
      - ./data/panel:/run/panel
    group_add:
      - "${PANEL_DOCKER_GID:-984}"
    read_only: true
    tmpfs:
      - /tmp:size=32m,mode=1777
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  # S3-compatible object storage (compose profile "rustfs"). RustFS is
  # Apache-2.0 and speaks the S3 API on 9000; its web console is served on
  # 9001 under /rustfs/console/. Services reach it as http://rustfs:9000, so
  # the image stays private to the stack network unless RUSTFS_BIND_IP is
  # changed to a LAN address for a reverse proxy. The Content Bot and Media
  # Studio keep their own artifacts on disk (see docs/S3-STORAGE.md for the
  # supported matrix); Open WebUI stores uploads here when
  # OPENWEBUI_STORAGE_PROVIDER=s3. ./manage.sh s3-enable prepares the data
  # directories and credentials, and ./manage.sh s3-verify proves them.
  rustfs:
    profiles: ["rustfs"]
    image: ${RUSTFS_IMAGE_REPOSITORY:-rustfs/rustfs}:${RUSTFS_IMAGE_TAG:-latest}
    container_name: rustfs
    restart: unless-stopped
    user: "${RUSTFS_UID:-10001}:${RUSTFS_GID:-10001}"
    environment:
      RUSTFS_VOLUMES: /data
      # Listen on every container interface; RUSTFS_BIND_IP/RUSTFS_PORT limit
      # the host port the same way MEDIA_STUDIO_BIND_IP does.
      RUSTFS_ADDRESS: "0.0.0.0:9000"
      RUSTFS_CONSOLE_ADDRESS: "0.0.0.0:9001"
      RUSTFS_CONSOLE_ENABLE: "true"
      RUSTFS_CONSOLE_PREFIX: ${RUSTFS_CONSOLE_PREFIX:-/rustfs/console}
      RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-}
      RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-}
      RUSTFS_REGION: ${RUSTFS_REGION:-us-east-1}
      RUSTFS_OBS_LOG_DIRECTORY: /logs
      # Empty keeps console links relative; set S3_PUBLIC_CONSOLE_URL when the
      # console is published through a reverse proxy.
      RUSTFS_BROWSER_REDIRECT_URL: ${S3_PUBLIC_CONSOLE_URL:-}
      TZ: ${N8N_TIMEZONE:-Asia/Tehran}
    ports:
      - "${RUSTFS_BIND_IP:-127.0.0.1}:${RUSTFS_PORT:-9000}:9000"
      - "${RUSTFS_CONSOLE_BIND_IP:-127.0.0.1}:${RUSTFS_CONSOLE_PORT:-9001}:9001"
    volumes:
      - ./data/rustfs/data:/data
      - ./data/rustfs/logs:/logs
    read_only: true
    tmpfs:
      - /tmp:size=64m,mode=1777
    cap_drop: ["ALL"]
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]
    healthcheck:
      test:
        - CMD
        - curl
        - -fsS
        - http://127.0.0.1:9000/health
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 20s

  # Instagram media host (compose profile "ig-media"): the Graph API only
  # accepts public HTTPS URLs, so the published media directory needs a public
  # address. nginx serves it read-only and stays bound to loopback; the
  # cloudflared sidecar opens the outbound tunnel and writes its assigned
  # hostname to a log the Content Bot reads, so a restarted tunnel never leaves
  # a stale URL behind. Set INSTAGRAM_MEDIA_PUBLIC_BASE_URL to a stable
  # hostname instead when the stack owns a domain.
  ig-media:
    profiles: ["ig-media"]
    image: nginx:alpine
    container_name: ig-media
    restart: unless-stopped
    ports:
      - "${IG_MEDIA_BIND_IP:-127.0.0.1}:${IG_MEDIA_PORT:-8099}:80"
    volumes:
      - ./data/content-bot/media:/usr/share/nginx/html/media:ro
      - ./content-bot/deploy/ig-media-nginx.conf:/etc/nginx/conf.d/default.conf:ro
    read_only: true
    tmpfs:
      - /var/cache/nginx:size=8m
      - /var/run:size=1m
      - /tmp:size=8m
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  # Quick tunnel (profile "ig-media-quick"): gives the nginx media host a
  # throwaway public hostname for testing. Enable it together with "ig-media"
  # through ./manage.sh instagram-media-enable; keep "ig-media" alone when a
  # reverse proxy or a named tunnel provides the public address.
  ig-media-tunnel:
    profiles: ["ig-media-quick"]
    image: cloudflare/cloudflared:latest
    container_name: ig-media-tunnel
    restart: unless-stopped
    depends_on: [ig-media]
    command:
      - tunnel
      - --no-autoupdate
      - --url
      - http://ig-media:80
      - --logfile
      - /data/trycloudflared.log
      - --loglevel
      - info
    environment:
      HOME: /tmp
      TMPDIR: /tmp
    user: "${IG_TUNNEL_RUN_AS:-1000:1000}"
    volumes:
      - ./data/content-bot/tunnel:/data
    read_only: true
    tmpfs:
      - /tmp:size=16m
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

  # Named Cloudflare tunnel for a stable public hostname (profile
  # "ig-media-named"). Enable it instead of the quick tunnel once
  # IG_MEDIA_TUNNEL_TOKEN holds a tunnel token from the Cloudflare dashboard;
  # cloudflared then keeps its public hostname, which the bot reads from
  # INSTAGRAM_MEDIA_PUBLIC_BASE_URL or data/content-bot/media-base-url.txt.
  # The tunnel's public hostname in the dashboard must point at http://ig-media:80.
  ig-media-named-tunnel:
    profiles: ["ig-media-named"]
    image: cloudflare/cloudflared:latest
    container_name: ig-media-named-tunnel
    restart: unless-stopped
    depends_on: [ig-media]
    command:
      - tunnel
      - --no-autoupdate
      - --logfile
      - /data/trycloudflared.log
      - --loglevel
      - info
      - run
      - --token
      - ${IG_MEDIA_TUNNEL_TOKEN:-}
    environment:
      HOME: /tmp
      TMPDIR: /tmp
    user: "${IG_TUNNEL_RUN_AS:-1000:1000}"
    volumes:
      - ./data/content-bot/tunnel:/data
    read_only: true
    tmpfs:
      - /tmp:size=16m
    security_opt:
      - no-new-privileges:true
    networks: [agent-net]

networks:
  agent-net:
    name: hermes-9router-net
    driver: bridge
  # Internal: the brokers cannot reach, and cannot be reached from, the internet
  # or the rest of the stack. Only Hermes and the brokers are attached.
  execution-control-net:
    name: hermes-execution-control
    driver: bridge
    internal: true
  # Outbound only, for SSH to reach configured hosts. Sandboxes join
  # hermes-execution-egress, which the Docker broker creates on demand; neither
  # network is attached to agent-net.
  # Dedicated host-ingress bridge for Execution Admin only. The execution-control
  # network remains internal; bind this service only to a trusted private address.
  execution-admin-ingress-net:
    name: hermes-execution-admin-ingress
    driver: bridge

  execution-egress-net:
    name: hermes-execution-egress
    driver: bridge
  # Approver-only outbound path for Telegram Bot API polling. Brokers are absent.
  execution-approval-egress-net:
    name: hermes-execution-approval-egress
    driver: bridge