# Канонический production-манифест Chatballs (ADR-HUB-0028). # Один Compose для: установки клиента, staging, smoke. # Локальная разработка — через override compose.dev.yaml; этот файл не содержит # dev source mounts, dev-команд и host-портов (кроме public 80/443 у gateway). # # Image references берутся из release.env (immutable digest refs, см. ADR §release). # В dev они не задаются — срабатывают безопасные defaults, а override подменяет # image на build. # # Запуск production/box: # docker compose --env-file .env --env-file release.env up -d # Запуск разработки: # docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up services: postgres: image: ${CHATBALLS_POSTGRES_IMAGE:-pgvector/pgvector:pg16} restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required} POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} POSTGRES_APP_USER: ${POSTGRES_APP_USER:?POSTGRES_APP_USER is required} POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD is required} POSTGRES_PLATFORM_USER: ${POSTGRES_PLATFORM_USER:?POSTGRES_PLATFORM_USER is required} POSTGRES_PLATFORM_PASSWORD: ${POSTGRES_PLATFORM_PASSWORD:?POSTGRES_PLATFORM_PASSWORD is required} POSTGRES_MIGRATION_USER: ${POSTGRES_MIGRATION_USER:?POSTGRES_MIGRATION_USER is required} POSTGRES_MIGRATION_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:?POSTGRES_MIGRATION_PASSWORD is required} volumes: - ${CHATBALLS_INSTANCE_DIR:-.}/data/postgres:/var/lib/postgresql/data - ${CHATBALLS_RELEASE_DIR:-.}/deploy/postgres/init-runtime-roles.sh:/docker-entrypoint-initdb.d/20-chatballs-runtime-roles.sh:ro - ${CHATBALLS_RELEASE_DIR:-.}/deploy/postgres/reassign-schema-ownership.sql:/chatballs-reassign-ownership.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 10 redis: image: ${CHATBALLS_REDIS_IMAGE:-redis:7-alpine} restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] volumes: - ${CHATBALLS_INSTANCE_DIR:-.}/data/redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 10 start_period: 20s # One-shot init: только миграции (ADR-HUB-0028 §one-shot init). Владельца и # демо-данные создаёт мастер первого запуска в браузере (/api/v1/setup/). # collectstatic испечён в backend-образ, здесь не вызывается. init: image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev} env_file: - ${CHATBALLS_INSTANCE_DIR:-.}/.env command: ["python", "manage.py", "migrate", "--noinput"] environment: CHATBALLS_DB_ROLE: migration restart: "no" depends_on: postgres: condition: service_healthy redis: condition: service_healthy backend-app: image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev} restart: unless-stopped env_file: - ${CHATBALLS_INSTANCE_DIR:-.}/.env command: > sh -c "gunicorn chatballs_backend.asgi_app:application --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers $${CHATBALLS_GUNICORN_WORKERS:-3} --timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}" environment: CHATBALLS_DB_ROLE: app volumes: # Legacy source media retained for the separately approved copy/hash # migration. Production writes use the required S3 backend in C04. - ${CHATBALLS_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media depends_on: init: condition: service_completed_successfully postgres: condition: service_healthy redis: condition: service_healthy healthcheck: test: [ "CMD", "python", "-c", "import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CHATBALLS_APP_HEALTHCHECK_HOST', 'app.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)", ] interval: 10s timeout: 5s retries: 10 start_period: 20s backend-platform: image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev} restart: unless-stopped env_file: - ${CHATBALLS_INSTANCE_DIR:-.}/.env command: > sh -c "gunicorn chatballs_backend.asgi_platform:application --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers $${CHATBALLS_GUNICORN_WORKERS:-3} --timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}" environment: CHATBALLS_DB_ROLE: platform depends_on: init: condition: service_completed_successfully postgres: condition: service_healthy redis: condition: service_healthy healthcheck: test: [ "CMD", "python", "-c", "import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CHATBALLS_PLATFORM_HEALTHCHECK_HOST', 'platform.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)", ] interval: 10s timeout: 5s retries: 10 start_period: 20s # Django admin не подключён к gateway. Единственный host bind — loopback; # удалённый доступ допускается только через SSH tunnel (ADR-HUB-0031). backend-admin: image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev} restart: unless-stopped env_file: - ${CHATBALLS_INSTANCE_DIR:-.}/.env command: > sh -c "gunicorn chatballs_backend.asgi_admin:application --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers $${CHATBALLS_GUNICORN_WORKERS:-2} --timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}" # Break-glass technical surface only: schema credentials are never used by # public app/platform runtimes and this service remains loopback-only. environment: CHATBALLS_DB_ROLE: migration ports: - "127.0.0.1:${CHATBALLS_ADMIN_PORT:-18001}:8000" depends_on: init: condition: service_completed_successfully postgres: condition: service_healthy redis: condition: service_healthy worker: image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev} restart: unless-stopped env_file: - ${CHATBALLS_INSTANCE_DIR:-.}/.env command: ["python", "manage.py", "run_worker"] environment: CHATBALLS_DB_ROLE: app volumes: - ${CHATBALLS_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media depends_on: backend-app: condition: service_healthy # Frontend: один nginx-образ со static-сборкой internal-ui и web-chat, # внутренняя маршрутизация (/api/, /ws/, /chat-widget.js, # /chat/, /calls/) — в deploy/nginx/frontend.production.conf. Не публикует # host-порт: public boundary — gateway (Caddy). frontend: image: ${CHATBALLS_FRONTEND_IMAGE:-chatballs-frontend:dev} restart: unless-stopped depends_on: backend-app: condition: service_healthy # Gateway: единственный HTTP/HTTPS public boundary (ADR-HUB-0028 §gateway). # Caddy: TLS termination, ACME, HTTP->HTTPS redirect, WebSocket upgrade (native). # Маршрутизация публичных путей делегируется frontend (internal). Coturn не # проксируется через Caddy — отдельная network boundary (profile calls). gateway: image: ${CHATBALLS_GATEWAY_IMAGE:-caddy:2.8.4} restart: unless-stopped environment: CHATBALLS_APP_DOMAIN: ${CHATBALLS_APP_DOMAIN:?CHATBALLS_APP_DOMAIN is required} CHATBALLS_PLATFORM_DOMAIN: ${CHATBALLS_PLATFORM_DOMAIN:?CHATBALLS_PLATFORM_DOMAIN is required} CHATBALLS_ACME_EMAIL: ${CHATBALLS_ACME_EMAIL:?CHATBALLS_ACME_EMAIL is required} ports: - "${CHATBALLS_WEB_LISTENING_IP:-0.0.0.0}:80:80" - "${CHATBALLS_WEB_LISTENING_IP:-0.0.0.0}:443:443" volumes: - ${CHATBALLS_RELEASE_DIR:-.}/Caddyfile:/etc/caddy/Caddyfile:ro - ${CHATBALLS_INSTANCE_DIR:-.}/data/caddy:/data - ${CHATBALLS_INSTANCE_DIR:-.}/data/caddy-config:/config depends_on: frontend: condition: service_started backend-app: condition: service_healthy backend-platform: condition: service_healthy # Coturn — медиа-relay для TURN fallback (SPEC-HUB-0013 §11). # Опциональный profile `calls` (ADR-HUB-0028): включается через COMPOSE_PROFILES=calls. # Host networking: relay использует широкий UDP-диапазон и реальный внешний IP, # публикуется напрямую и не проходит через HTTP reverse proxy. credentials # выдаёт backend по общему CHATBALLS_CALL_TURN_SECRET (static-auth-secret). coturn: profiles: ["calls"] image: ${CHATBALLS_COTURN_IMAGE:-coturn/coturn:4.6} restart: unless-stopped network_mode: host command: - -n - --log-file=stdout - --no-cli - --fingerprint - --use-auth-secret - --static-auth-secret=${CHATBALLS_CALL_TURN_SECRET} - --realm=${CHATBALLS_CALL_TURN_REALM} # Весь TURN живёт на выделенном публичном IP (отдельный порт/NIC), Caddy — на # основном IP. Это освобождает 443 под TURN-over-TLS без конфликта с web. - --listening-ip=${CHATBALLS_TURN_LISTENING_IP} - --relay-ip=${CHATBALLS_TURN_EXTERNAL_IP} - --external-ip=${CHATBALLS_TURN_EXTERNAL_IP} - --listening-port=${CHATBALLS_TURN_LISTENING_PORT:-3478} # TURN-over-TLS на 443: проходит через VPN/строгие сети, где UDP и 3478 режут. - --tls-listening-port=${CHATBALLS_TURN_TLS_PORT:-443} - --cert=/etc/coturn/certs/fullchain.pem - --pkey=/etc/coturn/certs/privkey.pem - --min-port=${CHATBALLS_TURN_MIN_PORT:-49160} # Relay-диапазон UDP (1 порт на media-endpoint; ~2 порта на звонок). # 49160-49999 = 840 портов ≈ 420 одновременных TURN-звонков на хосте. # Расширяется через CHATBALLS_TURN_MIN_PORT/CHATBALLS_TURN_MAX_PORT; все порты диапазона # должны быть открыты на firewall и не пересекаться с ephemeral-диапазоном ОС. - --max-port=${CHATBALLS_TURN_MAX_PORT:-49999} # Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом). - --no-multicast-peers - --no-tcp-relay - --denied-peer-ip=10.0.0.0-10.255.255.255 - --denied-peer-ip=172.16.0.0-172.31.255.255 - --denied-peer-ip=192.168.0.0-192.168.255.255 - --no-tlsv1 - --no-tlsv1_1 volumes: # LE-сертификат TURN-хоста (turns:), скопированный под uid coturn (nobody) # renewal deploy-hook'ом. См. docs по развёртыванию TURN-over-TLS. - ${CHATBALLS_INSTANCE_DIR:-.}/data/coturn-certs:/etc/coturn/certs:ro healthcheck: # Allocation smoke: STUN binding к собственному listener на выделенном IP. test: ["CMD", "turnutils_stunclient", "-p", "${CHATBALLS_TURN_LISTENING_PORT:-3478}", "${CHATBALLS_TURN_LISTENING_IP}"] interval: 30s timeout: 5s retries: 5 start_period: 15s