version: '3.8' # ── Security anchor template ──────────────────────────────────────────────────── # Applied to every service via <<: *security-defaults x-security-defaults: &security-defaults security_opt: - no-new-privileges:true # Prevent privilege escalation cap_drop: - ALL # Drop all Linux capabilities by default read_only: false # Override per-service if needed tmpfs: - /tmp:size=64m,noexec,nosuid,nodev x-backend-security: &backend-security security_opt: - no-new-privileges:true cap_drop: - ALL services: fifa2026-postgres: image: timescale/timescaledb:latest-pg16 restart: always <<: *backend-security cap_add: - CHOWN - SETUID - SETGID - DAC_OVERRIDE # postgres needs these minimal caps environment: POSTGRES_USER: fifa_user POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} POSTGRES_DB: fifa2026 volumes: - pg-data:/var/lib/postgresql/data - ./platform/backend/db_init_timescaledb.sql:/docker-entrypoint-initdb.d/init.sql:ro networks: - wc2026-net healthcheck: test: ["CMD-SHELL", "pg_isready -U fifa_user -d fifa2026"] interval: 10s timeout: 5s retries: 5 fifa2026-redis: image: redis:7-alpine restart: always <<: *backend-security cap_add: - SETUID - SETGID - CHOWN - DAC_OVERRIDE command: > redis-server --appendonly yes --protected-mode yes --bind 0.0.0.0 --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required} volumes: - redis-data:/data networks: - wc2026-net healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:?REDIS_PASSWORD is required}", "ping"] interval: 10s timeout: 5s retries: 5 fifa2026-backend: build: context: ./platform/backend restart: always <<: *security-defaults ports: - "127.0.0.1:8000:8000" extra_hosts: - "host.docker.internal:host-gateway" environment: - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:?DB_PASSWORD is required}@fifa2026-postgres:5432/fifa2026 - REDIS_URL=redis://:${REDIS_PASSWORD:?REDIS_PASSWORD is required}@fifa2026-redis:6379/0 - THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-} - THE_ODDS_BASE=${THE_ODDS_BASE:-https://api.the-odds-api.com} - THE_ODDS_SPORT_KEY=${THE_ODDS_SPORT_KEY:-soccer_fifa_world_cup} - THE_ODDS_REGIONS=${THE_ODDS_REGIONS:-eu} - THE_ODDS_MARKETS=${THE_ODDS_MARKETS:-h2h,spreads,totals,btts,draw_no_bet,h2h_3_way,alternate_spreads,alternate_totals,team_totals,alternate_team_totals} - THE_ODDS_ADDITIONAL_EVENTS_LIMIT=${THE_ODDS_ADDITIONAL_EVENTS_LIMIT:-24} - GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_MODEL=${GEMINI_MODEL:-gemini-3-flash-preview} - GEMINI_REVIEW_MODEL=${GEMINI_REVIEW_MODEL:-gemini-2.5-flash} - GEMINI_COST_CAP_USD=${GEMINI_COST_CAP_USD:-5} - GEMINI_INPUT_PRICE_PER_1M_USD=${GEMINI_INPUT_PRICE_PER_1M_USD:-0.50} - GEMINI_OUTPUT_PRICE_PER_1M_USD=${GEMINI_OUTPUT_PRICE_PER_1M_USD:-3.00} - GEMINI_GROUNDING_PRICE_PER_1K_USD=${GEMINI_GROUNDING_PRICE_PER_1K_USD:-14.00} - GEMINI_FALLBACK_REQUEST_COST_USD=${GEMINI_FALLBACK_REQUEST_COST_USD:-0.01} - NEMOTRON_API_BASE=${NEMOTRON_API_BASE:-} - NEMOTRON_MODEL=${NEMOTRON_MODEL:-nvidia/nemotron} - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-} - OLLAMA_NEMOTRON_MODEL=${OLLAMA_NEMOTRON_MODEL:-nemotron} - APP_TIME_ZONE=Asia/Taipei - TZ=Asia/Taipei depends_on: fifa2026-seed: condition: service_completed_successfully fifa2026-postgres: condition: service_healthy fifa2026-redis: condition: service_healthy networks: - wc2026-net healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s timeout: 10s retries: 3 fifa2026-seed: build: context: ./platform/backend restart: "no" <<: *backend-security command: ["python", "-m", "app.analytics.worldcup_seed"] environment: - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:?DB_PASSWORD is required}@fifa2026-postgres:5432/fifa2026 - TZ=Asia/Taipei depends_on: fifa2026-postgres: condition: service_healthy networks: - wc2026-net fifa2026-odds-worker: build: context: ./platform/backend restart: always <<: *security-defaults command: ["python", "-m", "app.analytics.crawler"] environment: - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:?DB_PASSWORD is required}@fifa2026-postgres:5432/fifa2026 - REDIS_URL=redis://:${REDIS_PASSWORD:?REDIS_PASSWORD is required}@fifa2026-redis:6379/0 - THE_ODDS_API_KEY=${THE_ODDS_API_KEY:-} - THE_ODDS_BASE=${THE_ODDS_BASE:-https://api.the-odds-api.com} - THE_ODDS_SPORT_KEY=${THE_ODDS_SPORT_KEY:-soccer_fifa_world_cup} - THE_ODDS_REGIONS=${THE_ODDS_REGIONS:-eu} - THE_ODDS_MARKETS=${THE_ODDS_MARKETS:-h2h,spreads,totals,btts,draw_no_bet,h2h_3_way,alternate_spreads,alternate_totals,team_totals,alternate_team_totals} - THE_ODDS_ADDITIONAL_EVENTS_LIMIT=${THE_ODDS_ADDITIONAL_EVENTS_LIMIT:-24} - ODDS_POLL_INTERVAL_SECONDS=${ODDS_POLL_INTERVAL_SECONDS:-300} - ESPN_SCOREBOARD_LOOKBACK_DAYS=${ESPN_SCOREBOARD_LOOKBACK_DAYS:-5} - ESPN_SCOREBOARD_LOOKAHEAD_DAYS=${ESPN_SCOREBOARD_LOOKAHEAD_DAYS:-2} - APP_TIME_ZONE=Asia/Taipei - TZ=Asia/Taipei depends_on: fifa2026-seed: condition: service_completed_successfully fifa2026-postgres: condition: service_healthy fifa2026-redis: condition: service_healthy networks: - wc2026-net fifa2026-news-worker: build: context: ./platform/backend restart: always <<: *security-defaults command: ["python", "-m", "app.analytics.news_worker"] environment: - REDIS_URL=redis://:${REDIS_PASSWORD:?REDIS_PASSWORD is required}@fifa2026-redis:6379/0 - NEWS_POLL_INTERVAL_SECONDS=${NEWS_POLL_INTERVAL_SECONDS:-900} - NEWS_QUERY=${NEWS_QUERY:-2026 FIFA World Cup OR 2026 世界盃 OR 世界盃 2026} - GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_MODEL=${GEMINI_MODEL:-gemini-3-flash-preview} - GEMINI_COST_CAP_USD=${GEMINI_COST_CAP_USD:-5} - TZ=Asia/Taipei depends_on: fifa2026-redis: condition: service_healthy networks: - wc2026-net fifa2026-agent-review-worker: build: context: ./platform/backend restart: always <<: *security-defaults command: ["python", "-m", "app.analytics.agent_review_worker"] extra_hosts: - "host.docker.internal:host-gateway" environment: - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:?DB_PASSWORD is required}@fifa2026-postgres:5432/fifa2026 - REDIS_URL=redis://:${REDIS_PASSWORD:?REDIS_PASSWORD is required}@fifa2026-redis:6379/0 - GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_MODEL=${GEMINI_MODEL:-gemini-3-flash-preview} - GEMINI_REVIEW_MODEL=${GEMINI_REVIEW_MODEL:-gemini-2.5-flash} - GEMINI_COST_CAP_USD=${GEMINI_COST_CAP_USD:-5} - GEMINI_INPUT_PRICE_PER_1M_USD=${GEMINI_INPUT_PRICE_PER_1M_USD:-0.50} - GEMINI_OUTPUT_PRICE_PER_1M_USD=${GEMINI_OUTPUT_PRICE_PER_1M_USD:-3.00} - GEMINI_GROUNDING_PRICE_PER_1K_USD=${GEMINI_GROUNDING_PRICE_PER_1K_USD:-14.00} - GEMINI_REVIEW_TIMEOUT_SECONDS=${GEMINI_REVIEW_TIMEOUT_SECONDS:-18} - NEMOTRON_API_BASE=${NEMOTRON_API_BASE:-} - NEMOTRON_MODEL=${NEMOTRON_MODEL:-nvidia/nemotron} - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-} - OLLAMA_NEMOTRON_MODEL=${OLLAMA_NEMOTRON_MODEL:-nemotron} - NEMOTRON_REVIEW_TIMEOUT_SECONDS=${AGENT_REVIEW_MODEL_TIMEOUT_SECONDS:-45} - NEMOTRON_REVIEW_NUM_PREDICT=${AGENT_REVIEW_NUM_PREDICT:-120} - AGENT_REVIEW_POLL_INTERVAL_SECONDS=${AGENT_REVIEW_POLL_INTERVAL_SECONDS:-1800} - AGENT_REVIEW_LOOKAHEAD_DAYS=${AGENT_REVIEW_LOOKAHEAD_DAYS:-2} - AGENT_REVIEW_CACHE_TTL_SECONDS=${AGENT_REVIEW_CACHE_TTL_SECONDS:-259200} - APP_TIME_ZONE=Asia/Taipei - TZ=Asia/Taipei depends_on: fifa2026-seed: condition: service_completed_successfully fifa2026-postgres: condition: service_healthy fifa2026-redis: condition: service_healthy networks: - wc2026-net fifa2026-calendar-cache-worker: build: context: ./platform/backend restart: always <<: *security-defaults command: ["python", "-m", "app.analytics.daily_card_calendar_worker"] environment: - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:?DB_PASSWORD is required}@fifa2026-postgres:5432/fifa2026 - REDIS_URL=redis://:${REDIS_PASSWORD:?REDIS_PASSWORD is required}@fifa2026-redis:6379/0 - DAILY_CARD_CALENDAR_POLL_INTERVAL_SECONDS=${DAILY_CARD_CALENDAR_POLL_INTERVAL_SECONDS:-300} - DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS=${DAILY_CARD_CALENDAR_CACHE_TTL_SECONDS:-420} - DAILY_CARD_CALENDAR_START_DATE=${DAILY_CARD_CALENDAR_START_DATE:-2026-06-11} - APP_TIME_ZONE=Asia/Taipei - TZ=Asia/Taipei depends_on: fifa2026-seed: condition: service_completed_successfully fifa2026-postgres: condition: service_healthy fifa2026-redis: condition: service_healthy networks: - wc2026-net fifa2026-fixtures-worker: build: context: ./platform/backend restart: always <<: *security-defaults command: ["python", "-m", "app.analytics.fixtures_worker"] environment: - DATABASE_URL=postgresql+asyncpg://fifa_user:${DB_PASSWORD:?DB_PASSWORD is required}@fifa2026-postgres:5432/fifa2026 - REDIS_URL=redis://:${REDIS_PASSWORD:?REDIS_PASSWORD is required}@fifa2026-redis:6379/0 - FIXTURES_JSON_URL=${FIXTURES_JSON_URL:-https://www.thestatsapi.com/world-cup/data/fixtures.json} - FIXTURES_POLL_INTERVAL_SECONDS=${FIXTURES_POLL_INTERVAL_SECONDS:-21600} - TZ=Asia/Taipei depends_on: fifa2026-seed: condition: service_completed_successfully fifa2026-postgres: condition: service_healthy fifa2026-redis: condition: service_healthy networks: - wc2026-net fifa2026-web: build: context: ./platform/web restart: always <<: *security-defaults ports: - "127.0.0.1:3108:3000" environment: - NEXT_PUBLIC_API_URL=https://2026fifa.wooo.work/api - NEXT_PUBLIC_WS_URL=wss://2026fifa.wooo.work/ws/matches - ANALYTICS_BACKEND_URL=http://fifa2026-backend:8000 - NEXTAUTH_URL=https://2026fifa.wooo.work - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?NEXTAUTH_SECRET is required} - DATABASE_URL=postgresql://fifa_user:${DB_PASSWORD:?DB_PASSWORD is required}@fifa2026-postgres:5432/fifa2026 - TZ=Asia/Taipei depends_on: fifa2026-backend: condition: service_healthy networks: - wc2026-net fifa2026-alerts: build: context: ./platform/alerts restart: always <<: *security-defaults environment: - REDIS_URL=redis://:${REDIS_PASSWORD:?REDIS_PASSWORD is required}@fifa2026-redis:6379/0 - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} depends_on: fifa2026-redis: condition: service_healthy networks: - wc2026-net networks: wc2026-net: driver: bridge volumes: pg-data: redis-data: