name: vidconf

# Глубина логов рассчитана на РАЗБОР ИНЦИДЕНТОВ, а не только на просмотр
# последних сообщений. При 10 МБ × 3 (прежнее значение) логи LiveKit на
# конференции в полсотни человек перезаписывались за часы — а именно по ним
# восстанавливаются вещи, которых нет в метриках: сколько камер было включено
# одновременно, кого и почему отключило, какие события congestion шли.
# 50 МБ × 5 = 250 МБ на контейнер; на сервере с 15 ГБ свободного места это
# незаметно, зато ретроспектива живёт неделями.
x-logging: &default-logging
  driver: json-file
  options:
    max-size: "50m"
    max-file: "5"

services:
  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-vidconf}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vidconf}
      POSTGRES_DB: ${POSTGRES_DB:-vidconf}
    # Публикуем ТОЛЬКО на loopback: postgres нужен внутри compose-сети
    # (backend/worker обращаются по имени `postgres:5432`). Доступ снаружи —
    # через SSH-туннель. Публикация на 0.0.0.0 открывала БД в интернет.
    ports:
      - "127.0.0.1:5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vidconf} -d ${POSTGRES_DB:-vidconf}"]
      interval: 5s
      timeout: 5s
      retries: 10
    logging: *default-logging
    # NOTE: btree_gist extension is created by an Alembic migration, not here.

  redis:
    image: redis:7
    restart: unless-stopped
    # requirepass обязателен: сам по себе loopback-биндинг порта (ниже) не
    # защищает от взлома через скомпрометированный/зловредный контейнер в
    # той же compose-сети — только внешнюю экспозицию. Инцидент безопасности
    # (см. .forcc/deploy/SESSION2-FINDINGS.md) начался именно с redis без
    # пароля. `${REDIS_PASSWORD:?...}` — без секрета в .env контейнер не
    # стартует, а не тихо поднимается без аутентификации.
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}"]
    # Публикуем ТОЛЬКО на loopback: redis используется исключительно внутри
    # compose-сети (backend/worker/livekit/egress по имени `redis:6379`).
    # Публикация на 0.0.0.0 без пароля = открытый redis в интернет — вектор
    # cron/replication-RCE (см. .forcc/deploy/SESSION2-FINDINGS.md).
    ports:
      - "127.0.0.1:6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}", "--no-auth-warning", "ping"]
      interval: 5s
      timeout: 5s
      retries: 10
    logging: *default-logging

  backend:
    build:
      context: ../backend
      dockerfile: Dockerfile
    restart: unless-stopped
    env_file:
      - ../.env
    environment:
      DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
      REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
      PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
      LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env}
      LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?LIVEKIT_API_SECRET не задан в .env}
      LIVEKIT_PUBLIC_URL: ${LIVEKIT_PUBLIC_URL:-ws://localhost:7880}
      # Внутренний server-to-server адрес LiveKit (RoomService/Egress API).
      # Жёстко перекрывает значение из .env: там LIVEKIT_URL=ws://localhost:7880
      # для запуска backend на хосте, а изнутри контейнера localhost — это сам
      # backend, и start_track_egress молча деградирует (warning, записи нет).
      LIVEKIT_URL: ws://livekit:7880
      # Общий с egress/worker-transcriber путь на volume `recordings` —
      # backend формирует по нему filepath для start_track_egress, сам
      # том backend'у не примонтирован (файлы пишет контейнер egress).
      RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}
      # Каталог загруженных аватаров — общий volume с nginx, который
      # раздаёт его напрямую по `location /media/` (alias), в обход backend.
      MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
      # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
      # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
      VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.24}
      # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
      # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
      # проверьте бюджет соединений с БД: каждый воркер держит свой пул
      # (DB_POOL_SIZE + DB_MAX_OVERFLOW), а у Postgres есть max_connections.
      UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
    # config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
    # только backend/), поэтому plugins.yaml монтируется отдельно.
    volumes:
      - ../config:/app/config:ro
      - media:/app/media
    # Публикуем ТОЛЬКО на loopback: backend нужен снаружи compose-сети
    # только для локального curl-дебага на сервере — nginx проксирует
    # запросы по имени `backend:8000` внутри docker-сети, публичный доступ
    # идёт исключительно через nginx (80/443).
    ports:
      - "127.0.0.1:8000:8000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 15s
    logging: *default-logging

  # --- Celery worker + beat: та же backend-сборка, но с примонтированным
  # пакетом workers/ (workers/ импортирует модели backend). ---
  # `-Q celery,summarize,notify`: на малых пресетах
  # поставки (1–3) один контейнер обслуживает суммаризацию, уведомления и
  # обслуживающие задачи (`celery` — дефолтная очередь); `transcription`
  # сюда не входит — её слушает только `worker-transcriber`(-gpu), см. их
  # комментарий ниже. Вынос очередей в отдельные реплики при масштабировании
  # — `docs/deploy/scaling.md`.
  worker:
    build:
      context: ../backend
      dockerfile: Dockerfile
    restart: unless-stopped
    # `--no-sync` во ВСЕХ вызовах `uv run` в этом файле: окружение собрано на
    # этапе build образа (`uv sync --frozen --no-dev`, backend/Dockerfile), а
    # без флага `uv run` синхронизирует venv заново при каждом запуске — и
    # тянет dev-группу (ruff, mypy, pytest), которой в проде делать нечего.
    # Для healthcheck'ов это особенно дорого: они дёргаются каждые 15 секунд
    # всю жизнь контейнера.
    command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker", "-B",
               "-Q", "celery,summarize,notify", "--loglevel=info"]
    env_file:
      - ../.env
    environment:
      DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
      REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
      PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
      PYTHONPATH: /app
    volumes:
      - ../workers:/app/workers:ro
      # plugins.yaml из корня репозитория (см. комментарий у сервиса backend)
      - ../config:/app/config:ro
      # tokenizer.json модели Qwen (профиль `llm`, скачан llm-model-init) —
      # нужен QwenTokenCounter для подсчёта токенов при чанкинге транскрипта
      # (backend/core/summarization/tokens.py). Без профиля `llm` том пуст —
      # QwenTokenCounter уходит в фолбэк-эвристику len(text)//3.
      - llm-models:/models/qwen:ro
    depends_on:
      redis:
        condition: service_healthy
    # Сервис worker использует тот же backend-образ (см. build выше), поэтому
    # без переопределения он наследует HEALTHCHECK из backend/Dockerfile
    # (curl /api/health), который здесь бессмысленен — у celery-контейнера
    # нет HTTP-сервера на 8000. Проверяем воркер через `celery ... inspect
    # ping`, как рекомендует документация Celery.
    healthcheck:
      test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"]
      interval: 15s
      timeout: 10s
      retries: 5
      start_period: 20s
    logging: *default-logging

  # --- Профили transcribe/transcribe-gpu: том whisper-cache создаётся
  # Docker root:root при первом использовании — случайно совпадает с uid
  # backend-образа (в backend/Dockerfile НЕТ директивы USER, процессы внутри
  # выполняются от root, uid 0), но фиксируем владельца явно, по образцу
  # `recordings-init` — страховка от смены дефолтного uid образа/докера
  # в будущем.
  whisper-cache-init:
    image: busybox:1.36
    command: ["chown", "-R", "0:0", "/models/whisper"]
    volumes:
      - whisper-cache:/models/whisper
    restart: "no"
    profiles: ["transcribe", "transcribe-gpu"]
    logging: *default-logging

  # Разовая предзагрузка модели faster-whisper уровня AI (иначе первая
  # транскрибация после старта воркера сама тянет веса из HuggingFace Hub —
  # тот же дефект, что и с правами тома выше). `WHISPER_MODEL`
  # пишет install.sh по выбранному пресету (3 → small, 4 → medium,
  # 5 → large-v3, см. ADR-004); путь кэша — `/models/whisper/<модель>`,
  # КОНТРАКТ с `backend/services/ai_tiers.py::WHISPER_MODELS_ROOT` (см.
  # докстринг `deploy/whisper/download-model.py`) — используется и профилем
  # `transcribe` (CPU, пресеты 3/4), и `transcribe-gpu` (пресет 5, max).
  whisper-model-init:
    build:
      context: ../backend
      dockerfile: Dockerfile
    entrypoint: ["uv", "run", "--no-sync", "python", "/download-model.py"]
    environment:
      WHISPER_MODEL: ${WHISPER_MODEL:-small}
      WHISPER_MODELS_ROOT: /models/whisper
    volumes:
      - ./whisper/download-model.py:/download-model.py:ro
      - whisper-cache:/models/whisper
    restart: "no"
    depends_on:
      whisper-cache-init:
        condition: service_completed_successfully
    profiles: ["transcribe", "transcribe-gpu"]
    logging: *default-logging

  # --- Профиль transcribe: отдельный celery-воркер очереди `transcription`
  # (faster-whisper, CPU — уровни AI `min`/`medium`, ADR-004: `medium`
  # остаётся CPU-only в матрице `backend/services/ai_tiers.py`, GPU для него —
  # ручная настройка администратора вне детекта). Изолирован от базового
  # `worker`, потому что ctranslate2/faster-whisper несовместимы с
  # prefork-пулом Celery (fork процесса после инициализации нативных
  # библиотек небезопасен) — здесь `--pool=solo --concurrency=1`, модель —
  # синглтон на процесс. Базовый `worker` очередь `transcription` не
  # слушает (см. его command выше — явный `-Q` без `transcription`).
  # Start with: docker compose --profile transcribe up -d
  # (livekit из профиля `media` должен быть поднят отдельно/вместе).
  worker-transcriber:
    build:
      context: ../backend
      dockerfile: Dockerfile
    restart: unless-stopped
    # Фиксированное имя узла (`--hostname`) нужно, чтобы healthcheck ниже
    # мог обратиться именно к этому воркеру: broker/redis общий с базовым
    # `worker`, и `celery inspect ping` без `--destination` опросит ВЕСЬ
    # кластер — упавший worker-transcriber остался бы "healthy", потому что
    # ответил бы базовый worker.
    command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
               "-Q", "transcription", "--pool=solo", "--concurrency=1",
               "--hostname=worker-transcriber@localhost", "--loglevel=info"]
    env_file:
      - ../.env
    environment:
      DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
      REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
      PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
      RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}
      PYTHONPATH: /app
    volumes:
      - ../workers:/app/workers:ro
      # plugins.yaml из корня репозитория (см. комментарий у сервиса backend)
      - ../config:/app/config:ro
      - recordings:/recordings:ro
      - whisper-cache:/models/whisper
    # Лимиты ресурсов CPU-bound транскрибации (faster-whisper на CPU).
    # `cpus`/`mem_limit` — прямые атрибуты Compose (применяются и без
    # swarm), в отличие от `deploy.resources`, который вне `docker stack
    # deploy` игнорируется.
    cpus: "4"
    mem_limit: 6g
    depends_on:
      redis:
        condition: service_healthy
      whisper-model-init:
        condition: service_completed_successfully
    # HTTP-эндпоинта нет — пинг celery, но именно этого узла (см. --hostname
    # в command выше), а не первого ответившего в общем кластере.
    healthcheck:
      test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"]
      interval: 15s
      timeout: 10s
      retries: 5
      start_period: 20s
    profiles: ["transcribe"]
    logging: *default-logging

  # --- Профиль transcribe-gpu: GPU-вариант транскрайбера, ТОЛЬКО уровень AI
  # `max` (ADR-004, `backend/services/ai_tiers.py`: `medium` в матрице
  # остаётся CPU-only, GPU для него — ручная настройка вне детекта, поэтому
  # отдельного GPU-профиля для пресета 4 нет — см. комментарий у
  # worker-transcriber выше). Собран из того же Dockerfile backend с
  # extras-группой `gpu` (`nvidia-cublas-cu12`, `nvidia-cudnn-cu12==9.*` —
  # объявлена в `backend/pyproject.toml`); действующий провайдер
  # `faster_whisper_gpu` выбирается конфигом (TIERS["max"], не этим файлом).
  # Взаимоисключаем c `worker-transcriber` через профили: одновременно оба
  # профиля не поднимаются (install.sh выбирает ровно один по пресету).
  worker-transcriber-gpu:
    build:
      context: ../backend
      dockerfile: Dockerfile
      args:
        WITH_GPU_EXTRA: "true"
    restart: unless-stopped
    command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
               "-Q", "transcription", "--pool=solo", "--concurrency=1",
               "--hostname=worker-transcriber-gpu@localhost", "--loglevel=info"]
    env_file:
      - ../.env
    environment:
      DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
      REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
      PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
      RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}
      PYTHONPATH: /app
      # cuBLAS/cuDNN9, поставленные extras-группой `gpu` в venv образа (не
      # системные библиотеки CUDA) — CTranslate2 находит их только через
      # LD_LIBRARY_PATH, см. ADR-004 (faster-whisper README).
      LD_LIBRARY_PATH: /app/.venv/lib/python3.12/site-packages/nvidia/cublas/lib:/app/.venv/lib/python3.12/site-packages/nvidia/cudnn/lib
    volumes:
      - ../workers:/app/workers:ro
      - ../config:/app/config:ro
      - recordings:/recordings:ro
      - whisper-cache:/models/whisper
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    depends_on:
      redis:
        condition: service_healthy
      whisper-model-init:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"]
      interval: 15s
      timeout: 10s
      retries: 5
      start_period: 30s
    profiles: ["transcribe-gpu"]
    logging: *default-logging

  nginx:
    # Собственный образ: nginx со вкомпилированной статикой фронтенд-SPA
    # (frontend/Dockerfile, multi-stage: Vite-сборка → nginx). Раздаётся во
    # ВСЕХ пресетах (сервис без profiles), поэтому http://localhost/ отдаёт
    # рабочий фронт, а не заглушку.
    build:
      context: ../frontend
      dockerfile: Dockerfile
      args:
        # Инлайнится Vite на этапе сборки (см. frontend/Dockerfile) — сейчас
        # фронтенд получает LiveKit URL в рантайме от backend (join.livekit_url,
        # см. backend/services/conference_access.py: LIVEKIT_PUBLIC_URL), эти
        # build-args пока ни на что не влияют (см. комментарий в
        # frontend/Dockerfile), переданы про запас на будущее.
        VITE_LIVEKIT_URL: ${VITE_LIVEKIT_URL:-}
        NEXT_PUBLIC_LIVEKIT_URL: ${NEXT_PUBLIC_LIVEKIT_URL:-}
    image: vidconf-nginx:latest
    restart: unless-stopped
    environment:
      # Подставляются штатным entrypoint'ом образа nginx (envsubst-on-templates,
      # см. deploy/nginx/nginx.conf.template) — список доменов и имя каталога
      # сертификата вынесены в параметры, а не хардкод.
      NGINX_SERVER_NAMES: ${NGINX_SERVER_NAMES:?NGINX_SERVER_NAMES не задан в .env (список доменов через пробел)}
      NGINX_CERT_NAME: ${NGINX_CERT_NAME:?NGINX_CERT_NAME не задан в .env (каталог в /etc/letsencrypt/live/)}
    volumes:
      - ./nginx/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
      # Готовит /etc/nginx/certs/{fullchain,privkey}.pem до старта nginx: либо
      # копирует боевой сертификат из /etc/letsencrypt, либо (если его нет —
      # локальная разработка) генерирует самоподписанный. См. сам скрипт.
      - ./nginx/docker-entrypoint-certs.sh:/docker-entrypoint.d/15-vidconf-certs.sh:ro
      # Аватары: тот же volume, что у backend — nginx раздаёт файлы
      # напрямую (`location /media/`, alias), в обход backend-процесса.
      - media:/media:ro
      # Реальные сертификаты Let's Encrypt — с хоста, только на чтение (в dev
      # без ./certbot-webroot Docker создаст /etc/letsencrypt пустым — скрипт
      # выше в этом случае сгенерирует самоподписанный сертификат).
      - /etc/letsencrypt:/etc/letsencrypt:ro
      # Webroot для ACME http-01 challenge (certbot renew).
      - ./certbot-webroot:/var/www/certbot:rw
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      backend:
        condition: service_healthy
    healthcheck:
      # /healthz (не /) — после добавления HTTP→HTTPS редиректа (443) `/`
      # на порту 80 отдаёт 301 вместо 200, что уронило бы healthcheck;
      # /healthz отвечает 200 без редиректа.
      test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:80/healthz"]
      interval: 10s
      timeout: 5s
      retries: 5
    logging: *default-logging

  # --- Media profile: LiveKit SFU + TURN. Disabled by default. ---
  # Start with: docker compose --profile media up -d
  livekit:
    image: livekit/livekit-server:latest
    restart: unless-stopped
    command: --config /etc/livekit.yaml
    # Реальный livekit.yaml генерируется из шаблона перед стартом
    # (deploy/render-templates.sh, вызывается install.sh) — use_external_ip/
    # node_ip/webhook.api_key статичны и не читают env напрямую, в отличие
    # от LIVEKIT_KEYS ниже (см. комментарий в livekit.yaml.template).
    volumes:
      - ./livekit/livekit.yaml:/etc/livekit.yaml:ro
    environment:
      # Формат LIVEKIT_KEYS СТРОГО "key: secret" (двоеточие + пробел),
      # иначе LiveKit падает в crash-loop с "Could not parse keys". Значение
      # обязательно в кавычках: YAML запрещает ": " внутри неквотированного
      # plain-скаляра.
      LIVEKIT_KEYS: "${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env}: ${LIVEKIT_API_SECRET:?LIVEKIT_API_SECRET не задан в .env}"
    # 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени
    # `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры
    # снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не
    # нужен. 7881/tcp и UDP-порт ниже — реальные медиа-порты, остаются
    # публичными.
    ports:
      - "127.0.0.1:7880:7880"  # HTTP/WebSocket signaling
      - "7881:7881"       # RTC TCP fallback
      # Один порт вместо диапазона (был 54000-54100/udp) — LiveKit
      # мультиплексирует все ICE-сессии через него (rtc.udp_port в
      # livekit.yaml.template), а не открывает по порту на участника.
      # На диапазон Docker поднимал по docker-proxy на КАЖДЫЙ порт —
      # 101 порт держали 101 лишний userland-процесс на медиапути.
      # 54000 выбран, как раньше: нижние диапазоны (50000+, 52000+) на
      # macOS заняты Steam/системными процессами и эфемерными QUIC.
      - "54000:54000/udp"  # WebRTC media (ICE, мультиплекс)
    depends_on:
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:7880/"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 10s
    profiles: ["media"]
    logging: *default-logging

  # Образ coturn/coturn — Dockerfile прописывает `USER nobody:nogroup`, и это
  # НЕ runtime-привилегия, которую можно сбросить: Docker exec'ает entrypoint
  # сразу от этого uid, root-фазы внутри контейнера нет вовсе (в отличие от
  # официального образа nginx, который стартует entrypoint от root и только
  # nginx-воркеры позже понижают права по директиве в конфиге — см.
  # deploy/nginx/docker-entrypoint-certs.sh). Значит coturn физически не может
  # сам прочитать приватный ключ Let's Encrypt (root:root, обычно 0600) —
  # никакой volume-опцией это не обойти, не ослабляя права на ключ на хосте.
  #
  # Решение — по образцу уже существующего `recordings-init`/`llm-models-init`
  # в этом файле: отдельный init-контейнер (busybox, дефолтный root) читает
  # /etc/letsencrypt (той же ro-монтировкой, что и у nginx) и копирует
  # fullchain/privkey в СВОЙ volume под правами 644 — это копия, а не
  # оригинал, оригинальный ключ на хосте прав не меняет. Копия достаточно
  # открыта, чтобы её прочитал nobody:nogroup внутри coturn.
  #
  # Если /etc/letsencrypt/live/<домен> не существует (dev, нет реальных
  # сертификатов) — команда ниже просто ничего не копирует и завершается
  # успешно; coturn стартует как раньше, без TLS (см. TURN_TLS_HOST в
  # render-templates.sh — вторая половина того же переключателя).
  coturn-certs-init:
    image: busybox:1.36
    command: >
      sh -c '
        SRC="/etc/letsencrypt/live/$$NGINX_CERT_NAME";
        if [ -f "$$SRC/fullchain.pem" ] && [ -f "$$SRC/privkey.pem" ]; then
          cp "$$SRC/fullchain.pem" /certs/cert.pem;
          cp "$$SRC/privkey.pem" /certs/key.pem;
          chmod 644 /certs/cert.pem /certs/key.pem;
          echo "[coturn-certs-init] сертификат $$SRC скопирован в volume coturn-certs";
        else
          echo "[coturn-certs-init] $$SRC не найден — TLS для coturn не настроен (норма для dev без TURN_TLS_HOST)";
        fi
      '
    environment:
      NGINX_CERT_NAME: ${NGINX_CERT_NAME:?NGINX_CERT_NAME не задан в .env}
    volumes:
      - /etc/letsencrypt:/etc/letsencrypt:ro
      - coturn-certs:/certs
    restart: "no"
    profiles: ["media"]
    logging: *default-logging

  coturn:
    image: coturn/coturn:latest
    restart: unless-stopped
    command: -c /etc/coturn/turnserver.conf
    # Секреты (static-auth-secret/realm/external-ip) НЕ передаются через
    # environment — coturn читает их ТОЛЬКО из файла конфигурации, запущенного
    # через `-c` (при CLI-флагах он умеет $(VAR), при файле — нет, см.
    # комментарий в deploy/coturn/turnserver.conf.template). Реальный
    # turnserver.conf генерируется из шаблона перед стартом
    # (deploy/render-templates.sh, вызывается install.sh).
    volumes:
      - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
      - coturn-certs:/etc/coturn/certs:ro
    depends_on:
      coturn-certs-init:
        condition: service_completed_successfully
    network_mode: host
    # Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
    # curl/wget, поэтому проверка процесса по имени не работает
    # ("pgrep: not found" → healthcheck всегда unhealthy). Вместо этого
    # опрашиваем сам TURN-сервер STUN-запросом через штатную утилиту
    # turnutils_stunclient, которая есть в образе.
    healthcheck:
      test: ["CMD-SHELL", "turnutils_stunclient -p 3478 127.0.0.1 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 10s
    profiles: ["media"]
    logging: *default-logging

  # --- Профиль transcribe: LiveKit Egress — запись per-track аудио для
  # последующей транскрибации (см. ADR-002). Egress запускает обработчик каждой записи как отдельный
  # процесс внутри своего же контейнера (не Docker-in-Docker — проверено
  # по официальной документации livekit/egress), поэтому доступ к
  # /var/run/docker.sock не требуется. Записи и без транскрибации никому
  # не нужны — profiles совпадает с worker-transcriber; livekit (профиль
  # `media`) при этом должен быть поднят отдельно/вместе.
  # Start with: docker compose --profile media --profile transcribe up -d
  # Именованный том recordings при первом создании принадлежит root (0:0,
  # drwxr-xr-x), а процесс egress работает от uid 1001 (gid 0) и не может
  # создавать в нём каталоги сеансов. Однократный init-контейнер отдаёт том
  # владельцу-egress до старта записи (иначе Track Egress падает с
  # "Local upload failed: mkdir ... permission denied").
  recordings-init:
    image: busybox:1.36
    command: ["chown", "-R", "1001:0", "/recordings"]
    volumes:
      - recordings:/recordings
    restart: "no"
    profiles: ["transcribe"]
    logging: *default-logging

  egress:
    image: livekit/egress:latest
    restart: unless-stopped
    volumes:
      - ./egress/egress.yaml:/etc/egress.yaml:ro
      - recordings:/recordings
    environment:
      EGRESS_CONFIG_FILE: /etc/egress.yaml
      # api_key/api_secret/ws_url — обязательные переменные окружения
      # (см. deploy/egress/egress.yaml); ws_url — внутренний адрес сервиса
      # livekit в docker-сети, НЕ LIVEKIT_PUBLIC_URL для браузера.
      LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env}
      LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?LIVEKIT_API_SECRET не задан в .env}
      LIVEKIT_WS_URL: ${LIVEKIT_WS_URL:-ws://livekit:7880}
    depends_on:
      # required: false — livekit живёт в профиле `media`, а не
      # `transcribe`; без этого `docker compose --profile transcribe up`
      # (без media) падает с ошибкой "service livekit ... is disabled".
      # На практике egress без livekit бесполезен — профили запускают
      # вместе (см. комментарий выше), но это не должно ломать валидацию
      # конфигурации, если кто-то поднимает профили по отдельности.
      livekit:
        condition: service_healthy
        required: false
      redis:
        condition: service_healthy
      recordings-init:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://127.0.0.1:8081/healthz"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 10s
    profiles: ["transcribe"]
    logging: *default-logging

  # --- Профили llm/llm-gpu: том llm-models создаётся Docker root:root при
  # первом использовании — по тем же соображениям, что и whisper-cache-init
  # выше (backend-образ и curlimages/curl оба в итоге пишут
  # от root — см. `user: "0:0"` у llm-model-init ниже), явный init-контейнер
  # фиксирует владельца, по образцу `recordings-init`.
  llm-models-init:
    image: busybox:1.36
    command: ["chown", "-R", "0:0", "/models/qwen"]
    volumes:
      - llm-models:/models/qwen
    restart: "no"
    profiles: ["llm", "llm-gpu"]
    logging: *default-logging

  # --- Профили llm/llm-gpu: локальный LLM-сервер для плагина суммаризации
  # QwenLocal (см. ADR-004). Один и тот же
  # init-контейнер обслуживает оба профиля — уровень AI (`LLM_MODEL_*`)
  # определяет install.sh при выборе пресета 3/4/5, а не профиль CPU/GPU.
  # Start with: docker compose --profile llm up -d
  #
  # Разовое скачивание GGUF-модели уровня AI и tokenizer.json в общий volume
  # `llm-models`. Идемпотентен — при повторном запуске (файлы уже в volume)
  # ничего не перекачивает, см. deploy/llm/download-model.sh.
  llm-model-init:
    image: curlimages/curl:8.10.1
    entrypoint: ["/bin/sh", "/download-model.sh"]
    environment:
      LLM_MODEL_FILE: ${LLM_MODEL_FILE:-qwen3.5-4b-instruct-q4_k_m.gguf}
      LLM_MODEL_URL: ${LLM_MODEL_URL:-https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf}
      LLM_MODEL_MIN_SIZE: ${LLM_MODEL_MIN_SIZE:-2000000000}
      LLM_TOKENIZER_FILE: ${LLM_TOKENIZER_FILE:-qwen3.5-4b-instruct.tokenizer.json}
      LLM_TOKENIZER_URL: ${LLM_TOKENIZER_URL:-https://huggingface.co/Qwen/Qwen3.5-4B/resolve/main/tokenizer.json}
    # Образ curlimages/curl по умолчанию работает от непривилегированного
    # curl_user (uid 100) — записать .part-файл в /models/qwen не получится
    # без явного root (том фиксирован под 0:0 в llm-models-init выше).
    user: "0:0"
    volumes:
      - ./llm/download-model.sh:/download-model.sh:ro
      - llm-models:/models/qwen
    restart: "no"
    depends_on:
      llm-models-init:
        condition: service_completed_successfully
    profiles: ["llm", "llm-gpu"]
    logging: *default-logging

  # LLM-сервер (CPU) на базе официального образа llama.cpp (OpenAI-
  # совместимый /v1/chat/completions, используется QwenLocal через
  # OpenAICompatClient). Конфигурация — исключительно через переменные
  # окружения LLAMA_ARG_* (штатный механизм образа, см.
  # tools/server/README.md проекта llama.cpp). Тег закреплён по номеру
  # сборки (не плавающий `:server`) — см. ADR-004, «Сводка рисков»,
  # п.1; актуальный тег проверен по реестру ghcr.io.
  llm:
    image: ghcr.io/ggml-org/llama.cpp:server-b10068
    restart: unless-stopped
    environment:
      LLAMA_ARG_MODEL: /models/qwen/${LLM_MODEL_FILE:-qwen3.5-4b-instruct-q4_k_m.gguf}
      # 8k токенов на чанк (верхняя граница чанкера) + промпт + до 1536
      # токенов вывода (max-уровень reduce), с запасом на служебные токены.
      LLAMA_ARG_CTX_SIZE: 16384
      LLAMA_ARG_HOST: 0.0.0.0
      LLAMA_ARG_PORT: 8080
      # Отключение thinking-режима (ADR-004: семейство Qwen3.5 Small — по
      # умолчанию выключен, но фиксируем явно). `--chat-template-kwargs
      # '{"enable_thinking":false}'`, упомянутый в ADR-004, на актуальной
      # версии llama.cpp помечен деприкейтед в пользу `--reasoning off`
      # (проверено по common/arg.cpp: одинаковый эффект —
      # `default_template_kwargs["enable_thinking"]="false"`, но без
      # предупреждения в логе на каждый запуск).
      LLAMA_ARG_REASONING: "off"
      # Экспозиция /metrics для Prometheus (job `llm`, deploy/monitoring/prometheus.yml).
      LLAMA_ARG_ENDPOINT_METRICS: 1
    volumes:
      - llm-models:/models/qwen:ro
    # Loopback-only: admin/debug доступ по ssh-туннелю, наружу не публикуется.
    ports:
      - "127.0.0.1:8080:8080"
    depends_on:
      llm-model-init:
        condition: service_completed_successfully
    # /health отдаёт 503 ("Loading model"), пока модель грузится в память,
    # и 200 ("status": "ok"), когда сервер готов принимать запросы —
    # штатный эндпоинт образа llama.cpp (curl есть в базовом слое образа,
    # см. .devops/cpu.Dockerfile проекта llama.cpp — используется как в
    # фирменном HEALTHCHECK образа).
    healthcheck:
      test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 30s
    profiles: ["llm"]
    logging: *default-logging

  # --- Профиль llm-gpu: GPU-вариант LLM-сервера, ТОЛЬКО уровень AI `max`
  # (ADR-004: единственный уровень с `base_url: http://llm-gpu:8080/v1` в
  # `backend/services/ai_tiers.py`). Официальный CUDA-образ llama.cpp
  # (CUDA 12); тег закреплён по номеру сборки, синхронно с `llm` выше.
  llm-gpu:
    image: ghcr.io/ggml-org/llama.cpp:server-cuda-b10068
    restart: unless-stopped
    environment:
      LLAMA_ARG_MODEL: /models/qwen/${LLM_MODEL_FILE:-qwen3.5-35b-a3b-instruct-q4_k_m.gguf}
      LLAMA_ARG_CTX_SIZE: 16384
      LLAMA_ARG_HOST: 0.0.0.0
      LLAMA_ARG_PORT: 8080
      # Полный offload в VRAM (ADR-004: max — обязательный GPU ≥16 ГБ,
      # рекомендовано 24 ГБ; 999 — общепринятое в доках llama.cpp значение
      # «офлоадить все слои», сервер сам ограничивает реальным числом слоёв
      # модели, если их меньше).
      LLAMA_ARG_N_GPU_LAYERS: 999
      LLAMA_ARG_REASONING: "off"
      LLAMA_ARG_ENDPOINT_METRICS: 1
    volumes:
      - llm-models:/models/qwen:ro
    # Loopback-only: admin/debug доступ по ssh-туннелю, наружу не публикуется.
    ports:
      - "127.0.0.1:8081:8080"
    # Алиас `llm` в сети compose (в дополнение к штатному `llm-gpu`) —
    # `llm` и `llm-gpu` взаимоисключающи по профилю (install.sh включает
    # ровно один), поэтому Prometheus (deploy/monitoring/prometheus.yml,
    # job `llm`) всегда скрейпит один и тот же адрес `llm:8080` независимо
    # от того, какой из двух реально поднят — без дублирования job'ов и
    # вечно "красного" targets для неиспользуемого профиля.
    networks:
      default:
        aliases:
          - llm
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    depends_on:
      llm-model-init:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 30s
    profiles: ["llm-gpu"]
    logging: *default-logging

  # --- Профиль monitoring: Prometheus + Grafana + exporter'ы.
  # Дашборд «Пайплайны пост-обработки» — deploy/monitoring/grafana/.
  # Start with: docker compose --profile monitoring up -d
  # (можно вместе с любыми другими профилями — независимый набор сервисов).
  prometheus:
    # Тег закреплён по версии (не `:latest`) — та же причина, что и у llm-образов.
    image: prom/prometheus:v3.13.1
    restart: unless-stopped
    # Первые два флага — дефолт образа; повторяем их явно, потому что
    # `command` перекрывает CMD целиком. Третий — срок хранения: дефолтных
    # 15 суток мало, когда нагрузку набирают неделями (наблюдение за
    # реальными конференциями вместо разового теста), и разбирать её потом
    # приходится задним числом. 30 суток при нынешних 100 МБ TSDB стоят
    # копеек — база растёт медленнее, чем кажется.
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--storage.tsdb.retention.time=30d'
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
      - prometheus_data:/prometheus
    # node-exporter живёт в host-сети (см. комментарий у него) и по имени
    # сервиса в docker-сети больше не резолвится. `host-gateway` — штатный
    # способ дать контейнеру адрес хоста, не завязываясь на конкретный IP
    # docker-моста.
    extra_hosts:
      - "host.docker.internal:host-gateway"
    # Loopback-only: админ-доступ по ssh-туннелю, наружу не публикуется.
    ports:
      - "127.0.0.1:9090:9090"
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9090/-/healthy || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 10s
    profiles: ["monitoring"]
    logging: *default-logging

  postgres-exporter:
    image: quay.io/prometheuscommunity/postgres-exporter:v0.20.1
    restart: unless-stopped
    environment:
      DATA_SOURCE_NAME: "postgresql://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}?sslmode=disable"
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9187/metrics >/dev/null || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 10s
    profiles: ["monitoring"]
    logging: *default-logging

  redis-exporter:
    image: oliver006/redis_exporter:v1.87.0-alpine
    restart: unless-stopped
    environment:
      REDIS_ADDR: "redis://redis:6379"
      REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}
    depends_on:
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9121/metrics >/dev/null || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 10s
    profiles: ["monitoring"]
    logging: *default-logging

  node-exporter:
    # Метрики железа хоста (CPU, память, диск, сеть, load average) — то,
    # чего нет ни в одном из приложенческих экспортеров выше.
    #
    # `network_mode: host` ОБЯЗАТЕЛЕН, и вот почему (проверено 2026-07-28,
    # до этого экспортер работал в bridge-сети и отдавал неверные данные).
    # Bind-mount'а `/proc` достаточно для CPU, памяти и диска, но НЕ для сети:
    # `/proc/net` — это симлинк на `self/net`, который резолвится в сетевом
    # namespace ЧИТАЮЩЕГО процесса. В bridge-сети экспортер видел собственные
    # `lo` и `eth0` (56 МБ трафика) вместо хостового `enp3s0` (39.8 ГБ), то
    # есть `node_network_*` показывал трафик контейнера, а не сервера. При
    # разборе нагрузочного теста 28.07 сетевых метрик хоста не оказалось
    # вовсе — см. .forcc/LOAD-FINDINGS.md.
    #
    # Порт 9100 при этом слушается на хосте. Наружу он не торчит: ufw
    # пропускает только 22/80/443/3478/7881/51820 и UDP-диапазон LiveKit
    # (проверено `ufw status`). Prometheus обращается к нему через
    # `host.docker.internal` (см. `extra_hosts` у сервиса prometheus и
    # таргет `node` в deploy/monitoring/prometheus.yml).
    image: prom/node-exporter:v1.8.2
    restart: unless-stopped
    pid: host
    network_mode: host
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro
    command:
      - '--path.procfs=/host/proc'
      - '--path.sysfs=/host/sys'
      - '--path.rootfs=/rootfs'
      - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
    # Секции `ports` нет и с host-сетью быть не может: контейнер слушает
    # прямо на интерфейсах хоста. От внешнего мира порт закрывает ufw.
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9100/metrics >/dev/null || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 10s
    profiles: ["monitoring"]
    logging: *default-logging

  # Метрики по контейнерам (CPU/память/сеть каждого отдельно) — замена
  # cAdvisor, который на сервере `1gb` несовместим с Docker Engine
  # (containerd-снапшоттер вместо classic overlay2 — подробности и
  # перепробованные варианты см. `.forcc/JOURNAL.md`, раздел релиза 0.0.9).
  # Вместо интроспекции graph-driver'а — Docker Engine API напрямую
  # (`GET /containers/json` + `/stats`), он не зависит от storage-driver.
  #
  # Доступ к докер-сокету равносилен root на хосте (`:ro` при монтировании
  # ограничивает права на файл-ноду, а не на протокол — через сокет можно
  # поднять привилегированный контейнер и получить хост). Поэтому сокет
  # монтируется СЮДА, в доверенный прокси-образ, а НЕ в наш собственный
  # `container-exporter` ниже — прокси разрешает ему только
  # `GET /containers/json` и `GET /containers/*/stats` (`CONTAINERS=1`),
  # любые изменяющие запросы заблокированы (`POST=0`). Полная компрометация
  # `container-exporter` не даёт управлять Docker. Обоснование и разбор —
  # `docs/deploy/monitoring.md`.
  docker-socket-proxy:
    image: tecnativa/docker-socket-proxy:v0.5.0
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      CONTAINERS: 1
      # POST=0 — дефолт образа; выписан явно, потому что от него зависит
      # безопасность всей схемы (полагаться на невыписанный дефолт для
      # критичного флага не стоит).
      POST: 0
      EVENTS: 0
    # Не публикуем порт наружу вообще — container-exporter достаёт API
    # по имени сервиса во внутренней сети compose, тот же принцип, что и
    # у node-exporter/postgres-exporter выше.
    healthcheck:
      test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1:2375/_ping"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 10s
    profiles: ["monitoring"]
    logging: *default-logging

  container-exporter:
    build:
      context: ./monitoring/container-exporter
      dockerfile: Dockerfile
    restart: unless-stopped
    environment:
      DOCKER_API_BASE: "http://docker-socket-proxy:2375"
    depends_on:
      docker-socket-proxy:
        condition: service_healthy
    # Без ports — тот же принцип, что и у прокси выше; healthcheck
    # прописан в самом Dockerfile экспортера (как у backend/worker).
    profiles: ["monitoring"]
    logging: *default-logging

  grafana:
    image: grafana/grafana:13.1.0
    restart: unless-stopped
    environment:
      GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
      # Дефолт только для dev — install.sh генерирует секрет при первой
      # установке (секреты только в .env).
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-change-me-grafana}
      GF_USERS_ALLOW_SIGN_UP: "false"
    volumes:
      - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
      - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
      - grafana_data:/var/lib/grafana
    # Loopback-only: админ-доступ по ssh-туннелю, наружу не публикуется.
    ports:
      - "127.0.0.1:3001:3000"
    depends_on:
      prometheus:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "curl -sf http://127.0.0.1:3000/api/health || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 15s
    profiles: ["monitoring"]
    logging: *default-logging

volumes:
  postgres_data:
  redis_data:
  # Общий том между egress (пишет) и worker-transcriber (читает :ro):
  # per-track .ogg-записи (profile transcribe).
  recordings:
  # Кэш весов faster-whisper — переживает пересоздание контейнера. Каждый
  # уровень AI хранит модель в подкаталоге `<volume>/<модель>` (`small`/
  # `medium`/`large-v3`) — КОНТРАКТ с `backend/services/ai_tiers.py`
  # (WHISPER_MODELS_ROOT) и детектом доступности уровня, см.
  # `deploy/whisper/download-model.py`.
  whisper-cache:
  # GGUF-модель Qwen3.5 уровня AI + tokenizer.json (профили `llm`/`llm-gpu`,
  # скачивает llm-model-init) — общий том между `llm`/`llm-gpu` (инференс) и
  # `worker` (подсчёт токенов чанкером через QwenTokenCounter). Имена файлов
  # — КОНТРАКТ с `backend/services/ai_tiers.py` (QWEN_MODELS_ROOT).
  llm-models:
  # Загруженные пользователями файлы (аватары) — общий том между
  # backend (запись при загрузке) и nginx (раздача статики, `location /media/`).
  media:
  # Копия fullchain/privkey Let's Encrypt под правами 644 для coturn
  # (nobody:nogroup) — источник в /etc/letsencrypt не трогаем, см.
  # coturn-certs-init выше. Обновляется при каждом перезапуске
  # coturn-certs-init (deploy-hook certbot делает это при продлении).
  coturn-certs:
  # Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера.
  prometheus_data:
  # Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.
  grafana_data:
