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