services: postgres: build: context: ./docker/postgres dockerfile: Dockerfile container_name: tasknebula-postgres restart: unless-stopped # Load pg_stat_statements at server start so the extension created in # init.sql can actually record statements. See docs/OBSERVABILITY.md. command: - postgres - -c - shared_preload_libraries=pg_stat_statements - -c - pg_stat_statements.track=all - -c - pg_stat_statements.max=10000 environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: ${POSTGRES_DB:-tasknebula} ports: # Bind to localhost only to prevent external network access to the database. - '127.0.0.1:${DB_PORT:-5432}:5432' volumes: - postgres_data:/var/lib/postgresql/data - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql healthcheck: test: ['CMD-SHELL', 'pg_isready -U postgres'] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine container_name: tasknebula-redis restart: unless-stopped command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD must be set} environment: REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD must be set} ports: - '127.0.0.1:${REDIS_PORT:-6379}:6379' volumes: - redis_data:/data healthcheck: test: ['CMD-SHELL', 'redis-cli --no-auth-warning -a "$$REDIS_PASSWORD" ping | grep -q PONG'] interval: 10s timeout: 5s retries: 5 livekit: image: livekit/livekit-server:v1.10.1 container_name: tasknebula-livekit restart: unless-stopped network_mode: host depends_on: redis: condition: service_healthy entrypoint: ['/bin/sh', '/etc/livekit/start-livekit.sh'] environment: LIVEKIT_NODE_IP: ${LIVEKIT_NODE_IP:-} LIVEKIT_PORT: ${LIVEKIT_PORT:-7880} LIVEKIT_TCP_PORT: ${LIVEKIT_TCP_PORT:-7881} LIVEKIT_TURN_UDP_PORT: ${LIVEKIT_TURN_UDP_PORT:-3478} LIVEKIT_RTC_START_PORT: ${LIVEKIT_RTC_START_PORT:-50000} LIVEKIT_RTC_END_PORT: ${LIVEKIT_RTC_END_PORT:-50020} LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-tasknebula-dev} LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?LIVEKIT_API_SECRET must be set} LIVEKIT_WEBHOOK_URL: ${LIVEKIT_WEBHOOK_URL:-http://127.0.0.1:${PORT:-3000}/api/chat/livekit/webhook} # Prometheus exporter (OBS-35). Default 0 = disabled; set to e.g. 6789 # to expose http://:6789/metrics. See docs/OBSERVABILITY.md. LIVEKIT_PROMETHEUS_PORT: ${LIVEKIT_PROMETHEUS_PORT:-0} REDIS_PORT: ${REDIS_PORT:-6379} REDIS_PASSWORD: ${REDIS_PASSWORD} volumes: - ./docker/livekit/start-livekit.sh:/etc/livekit/start-livekit.sh:ro healthcheck: test: ['CMD-SHELL', 'wget -qO- http://localhost:7880/ | grep -q OK || exit 1'] interval: 30s timeout: 5s start_period: 20s retries: 3 web: # Pull the published image by default (instant start). Override via # TASKNEBULA_IMAGE=... or run `docker compose build web` to rebuild from # source — the `build:` stanza below is the fallback when the image is # not available locally or on the registry. image: ${TASKNEBULA_IMAGE:-neuraparse/tasknebula:latest} build: context: . dockerfile: Dockerfile args: AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET must be set (generate with `openssl rand -base64 32`)} AUTH_URL: ${APP_URL:-http://localhost:3000} NEXT_PUBLIC_APP_URL: ${APP_URL:-http://localhost:3000} container_name: tasknebula-web restart: unless-stopped depends_on: postgres: condition: service_healthy redis: condition: service_healthy livekit: condition: service_started environment: DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-tasknebula} AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET must be set (generate with `openssl rand -base64 32`)} AUTH_URL: ${APP_URL:-http://localhost:3000} NEXT_PUBLIC_APP_URL: ${APP_URL:-http://localhost:3000} NEXT_PUBLIC_APP_NAME: ${APP_NAME:-TaskNebula} REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 LIVEKIT_URL: ${LIVEKIT_URL:-http://host.docker.internal:7880} NEXT_PUBLIC_LIVEKIT_URL: ${NEXT_PUBLIC_LIVEKIT_URL:-ws://${LIVEKIT_PUBLIC_HOST:-127.0.0.1}:7880} LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-tasknebula-dev} LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?LIVEKIT_API_SECRET must be set} TURN_URL: ${TURN_URL:-} TURN_USERNAME: ${TURN_USERNAME:-} TURN_PASSWORD: ${TURN_PASSWORD:-} # OAuth (optional) GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} SKIP_SEED: ${SKIP_SEED:-false} NODE_ENV: production # Email / SMTP (optional) SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-25} SMTP_USER: ${SMTP_USER:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_SECURE: ${SMTP_SECURE:-false} EMAIL_FROM: ${EMAIL_FROM:-} FEATURE_EMAIL_ENABLED: ${FEATURE_EMAIL_ENABLED:-false} # AI / agents / bots — enabled from Admin → Agent control (DB-backed). # These env vars are an optional last-resort credential fallback for # dev; prod should enter keys through the admin/workspace config UI. OPENAI_API_KEY: ${OPENAI_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} # Cron endpoints (POST /api/cron/{standup,janitor}) are guarded by a # shared secret. Set CRON_SECRET to enable them. The optional # JANITOR_SYSTEM_USER_ID is the user id used as the author for janitor # comments and updates; without it the janitor runs in dry-run mode. CRON_SECRET: ${CRON_SECRET:-} JANITOR_SYSTEM_USER_ID: ${JANITOR_SYSTEM_USER_ID:-} ports: - '127.0.0.1:${PORT:-3000}:3000' volumes: - uploads_data:/app/uploads extra_hosts: - 'host.docker.internal:host-gateway' healthcheck: test: [ 'CMD', 'node', '-e', "require('http').get('http://localhost:3000/api/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))", ] interval: 30s timeout: 10s start_period: 60s retries: 3 # Optional: lightweight cron sidecar that calls the standup + janitor # endpoints on a schedule. Comment out if you prefer Vercel Cron, k8s # CronJobs, or an external scheduler. The container uses busybox so the # whole footprint is < 5 MB. cron: image: alpine:3.20 container_name: tasknebula-cron profiles: ['cron'] restart: unless-stopped depends_on: web: condition: service_started environment: CRON_SECRET: ${CRON_SECRET:-} WEB_BASE_URL: ${CRON_WEB_BASE_URL:-http://web:3000} entrypoint: ['/bin/sh', '-c'] # Daily standup at 08:00 UTC, hourly janitor, daily cycle-rollover at # 02:00 UTC (off-peak so any cross-cycle DB churn doesn't collide with # the standup digest window). busybox crond reads /etc/crontabs/root. command: - | if [ -z "$$CRON_SECRET" ]; then echo "CRON_SECRET must be set to enable the cron sidecar" >&2 exit 1 fi apk add --no-cache curl > /dev/null && \ printf '0 8 * * * curl -fsS -X POST -H "x-cron-secret: $$CRON_SECRET" "$$WEB_BASE_URL/api/cron/standup" -o /dev/null\n0 * * * * curl -fsS -X POST -H "x-cron-secret: $$CRON_SECRET" "$$WEB_BASE_URL/api/cron/janitor" -o /dev/null\n0 2 * * * curl -fsS -X POST -H "x-cron-secret: $$CRON_SECRET" "$$WEB_BASE_URL/api/cron/cycle-rollover" -o /dev/null\n' > /etc/crontabs/root && \ crond -f -l 8 volumes: postgres_data: redis_data: uploads_data: