# Worker services — managed separately from app (no blue-green needed). # BullMQ queues are durable in Redis; jobs survive worker restart. # # Usage: docker compose -f docker-compose.workers.yml up -d x-worker-image: &worker-image image: ${SOTTO_WORKERS_IMAGE:-sotto-workers}:${SOTTO_IMAGE_TAG:-local} build: context: . dockerfile: apps/web/Dockerfile.workers args: WORKER_BASE_IMAGE: ${SOTTO_WORKER_BASE_IMAGE:-ghcr.io/affromero/sotto-workers-base:node24} RUNTIME_UID: '1000' # Web and workers share the CLI homes as UID 1000. volumes: # Workers generate the TTS and episode audio that web serves back over # /api/v1/storage, so web and workers must mount the same local store. - storage:/app/apps/web/.sotto/storage - executions:/app/apps/web/.sotto/runtime - cli-credentials:/run/cli-credentials - agent-home:/home/sotto/.claude - agent-codex-home:/home/sotto/.codex services: workers-heavy: <<: *worker-image restart: unless-stopped deploy: resources: limits: memory: 3g env_file: - .env environment: NODE_ENV: production DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL} REDIS_URL: ${REDIS_URL:?Set REDIS_URL} SOTTO_CREDENTIAL_SYNC_DIR: /run/cli-credentials LOCAL_STORAGE_DIR: ${LOCAL_STORAGE_DIR:-/app/apps/web/.sotto/storage} SIDEDOOR_EXECUTION_DIR: /app/apps/web/.sotto/runtime/executions CLAUDE_HOME: /home/sotto/.claude CODEX_HOME: /home/sotto/.codex WORKER_PROFILE: heavy WORKER_PRESET: ${WORKER_PRESET_HEAVY:-full} WORKER_QUEUES: ${WORKER_QUEUES_HEAVY:-} WORKER_EXCLUDE_QUEUES: ${WORKER_EXCLUDE_QUEUES_HEAVY:-} command: ['node', '--max-old-space-size=2560', '--import', 'tsx', 'src/workers/index.ts'] networks: - sotto-network logging: driver: json-file options: max-size: '50m' max-file: '5' workers-pipeline: <<: *worker-image restart: unless-stopped deploy: resources: limits: memory: 1g env_file: - .env environment: NODE_ENV: production DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL} REDIS_URL: ${REDIS_URL:?Set REDIS_URL} SOTTO_CREDENTIAL_SYNC_DIR: /run/cli-credentials LOCAL_STORAGE_DIR: ${LOCAL_STORAGE_DIR:-/app/apps/web/.sotto/storage} SIDEDOOR_EXECUTION_DIR: /app/apps/web/.sotto/runtime/executions CLAUDE_HOME: /home/sotto/.claude CODEX_HOME: /home/sotto/.codex WORKER_PROFILE: pipeline WORKER_PRESET: ${WORKER_PRESET_PIPELINE:-full} WORKER_QUEUES: ${WORKER_QUEUES_PIPELINE:-} WORKER_EXCLUDE_QUEUES: ${WORKER_EXCLUDE_QUEUES_PIPELINE:-} command: ['node', '--max-old-space-size=768', '--import', 'tsx', 'src/workers/index.ts'] networks: - sotto-network logging: driver: json-file options: max-size: '50m' max-file: '5' workers-light: <<: *worker-image restart: unless-stopped deploy: resources: limits: memory: 768m env_file: - .env environment: NODE_ENV: production DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL} REDIS_URL: ${REDIS_URL:?Set REDIS_URL} SOTTO_CREDENTIAL_SYNC_DIR: /run/cli-credentials LOCAL_STORAGE_DIR: ${LOCAL_STORAGE_DIR:-/app/apps/web/.sotto/storage} SIDEDOOR_EXECUTION_DIR: /app/apps/web/.sotto/runtime/executions CLAUDE_HOME: /home/sotto/.claude CODEX_HOME: /home/sotto/.codex WORKER_PROFILE: light WORKER_PRESET: ${WORKER_PRESET_LIGHT:-full} WORKER_QUEUES: ${WORKER_QUEUES_LIGHT:-} WORKER_EXCLUDE_QUEUES: ${WORKER_EXCLUDE_QUEUES_LIGHT:-} command: ['node', '--max-old-space-size=512', '--import', 'tsx', 'src/workers/index.ts'] networks: - sotto-network logging: driver: json-file options: max-size: '20m' max-file: '3' credential-sync: image: alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce restart: unless-stopped logging: driver: json-file options: max-size: '20m' max-file: '3' network_mode: none read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - DAC_READ_SEARCH volumes: - ${CLAUDE_AUTH_DIR:-/dev/null}:/host-claude:ro - ${CODEX_AUTH_DIR:-/dev/null}:/host-codex:ro - cli-credentials:/credential-sync - ./scripts/agent/sync-cli-credentials.sh:/usr/local/bin/sync-cli-credentials:ro command: ['/bin/sh', '/usr/local/bin/sync-cli-credentials'] healthcheck: test: ['CMD', 'test', '-f', '/credential-sync/ready'] interval: 2s timeout: 1s retries: 15 networks: sotto-network: external: true name: sotto-network volumes: # Host-wide, shared with every stack: whichever process refreshes the OAuth # token first, the rotation is immediately the truth for all the others. agent-home: name: agent-claude-home agent-codex-home: name: agent-codex-home cli-credentials: name: ${SOTTO_STACK:?Set SOTTO_STACK}-cli-credentials # Stack-scoped but NOT project-scoped: web runs blue/green under two project # names and the workers are a third, so an unnamed volume would give each its # own empty store and every local asset would vanish on the next swap. storage: name: ${SOTTO_STACK:?Set SOTTO_STACK}-storage executions: name: ${SOTTO_STACK:?Set SOTTO_STACK}-executions