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 (../-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