# Production compose. Pulls pre-built images from GHCR # (built and pushed by .github/workflows/deploy.yml). # # Place at /opt/gendesign/docker-compose.prod.yml on the VM. # Required env in /opt/gendesign/.env: # POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD # IMAGE_TAG (defaults to "latest") # # Optional (LLM chat #960/#957 — dormant until BOTH set; written to # backend/.env.runtime by deploy.yml from Forgejo secret/var only when non-empty): # OPENAI_API_KEY — Forgejo Actions secret (sensitive); unset → openai_api_key=None # LLM_ENABLED — Forgejo Actions variable (bool, "true"); unset → llm_enabled=False # # In production the browser talks to Caddy on :80/:443. # Caddy proxies /api/* and /health straight to backend, the rest to frontend. # So the frontend uses same-origin relative URLs — no NEXT_PUBLIC_API_BASE_URL needed. # # Postgres + Redis run alongside the app on the same VM (Discovery mode). # Volumes are shared with docker-compose.yml so switching between files preserves data. # # ── logging: journald (#2761) ──────────────────────────────────────────────── # До этого у стека НЕ БЫЛО потолка вообще: дефолтный json-file растёт без границ # и живёт в /var/lib/docker/containers// (умирает вместе с контейнером). # Тот же anchor и тот же драйвер, что у trade-in (#2758/#2741) — намеренно ОДИН # способ на обе половины, расхождение двух стеков дороже в поддержке. # # Замер прод 2026-08-06 (МБ/сутки = размер json-file / возраст контейнера): # glitchtip-worker 31.9 ← 2.59 ГБ накоплено, 81% всего роста стека # postgres 2.2 ← 162 МБ за 74 дня # backend 1.9 · worker 1.3 · beat 0.6 · caddy 0.5 · остальные <0.5 # ИТОГО ~39 МБ/сутки # Бюджет journald (замер там же, сообщение самого systemd-journald): # "System Journal ... is 2.2G, max 4.0G" — потолок 4G ЭМПИРИЧЕСКИ подтверждён # (journald.conf пуст, все дефолты; 10% от 145G = 14.5G, но капается 4G). # Системный поток 2.2G/103 суток ≈ 22 МБ/сутки. После этой правки # 22 + 39 + tradein(единицы) ≈ 65 МБ/сутки → 4096/65 ≈ 60 суток глубины. # Дисковый эффект ОТРИЦАТЕЛЬНЫЙ (в нашу пользу): 4G — это потолок с # самовытеснением, а сегодня glitchtip-worker растёт БЕЗ потолка; плюс # пересоздание контейнера удаляет его json-file → разово освобождает ~2.6 ГБ. # # КАК ЧИТАТЬ (проверено на проде 2026-08-06, ровно тем доступом, что есть): # docker logs gendesign-backend-1 # как и раньше — только текущий контейнер # # История через пересоздания: журнал принадлежит root:systemd-journal, а # # deploy-юзер gendesign состоит в docker/sudo, но НЕ в adm/systemd-journal, и # # sudo просит пароль (`sudo -n` молча падает) → голый journalctl даёт # # "No entries". Рабочий однострочник — через docker-группу: # docker run --rm -v /:/host:ro alpine chroot /host sh -c \ # 'TZ=UTC journalctl -t gendesign-backend-1 -o short-iso --since "2026-08-07 00:00"' # # TZ=UTC обязателен: --since/--until разбираются в ЛОКАЛЬНОМ времени хоста # # (+03), и флаг --utc на это НЕ влияет — он меняет только вывод (#2760). # # По метке контейнера: CONTAINER_NAME=gendesign-backend-1 (или CONTAINER_ID= # # — так читается лог УЖЕ УДАЛЁННОГО контейнера). # Владельцу стоит разово выдать `usermod -aG adm gendesign` — тогда journalctl # заработает напрямую (host-config, не этот файл). До этого правка не регрессия. # # ⚠️ Blast radius ПЕРВОГО деплоя: log-driver — свойство создания контейнера, так # что `compose up -d` пересоздаст ВСЁ. backend/worker/beat/caddy/forwarder и так # force-recreate'ятся каждым деплоем (см. deploy.yml) — ИНКРЕМЕНТ этой правки: # postgres (~10с даунтайма), redis (брокер celery), osrm + osrm-walk (перезагрузка # MLD-графа в RAM), frontend, glitchtip-web/worker. Разово, деплоить в окно без # ночных прогонов. # Ceiling: journald рейт-лимитит 10000 сообщений / 30s на сервис (дефолт) — при # флуде в журнал попадёт "Suppressed N messages". Текущий пик (glitchtip-worker # 31.9 МБ/сутки ≈ 3 строки/с) ниже лимита на три порядка; если появится — это # host drop-in journald.conf.d, не этот файл. # tag: имя контейнера, а не ID — SYSLOG_IDENTIFIER стабилен между пересозданиями. # # ⚠️ НЕ переносить этот anchor в корневой docker-compose.yml: он для локальной # разработки, а в Docker Desktop (macOS/Windows) journald в VM нет — контейнеры # просто не стартуют. Ceiling для dev-логов при нужде — json-file max-size. x-logging: &default-logging driver: journald options: tag: "{{.Name}}" services: postgres: image: postgis/postgis:16-3.4 logging: *default-logging restart: unless-stopped # #2812: /dev/shm под dynamic_shared_memory_type=posix. Умолчание Docker — 64 МБ, # и параллельные планы кладут туда свои DSM-сегменты. Прод-замер 2026-08-10: # база постоянно держит ~9.8 МиБ (DSA кумулятивной статистики pgstat), один # параллельный запрос Объектива берёт ~15.4 МиБ → 4-й одновременный не влезает # в 64 МиБ и падает `DiskFull: could not resize shared memory segment`. Ровно это # и случилось: 6 отказов за 1.2 с (market_metrics / sales_series / special_indices). # 1 ГиБ = ~65 таких запросов; потолок celery (--concurrency=8) + request-path ≈ 12. # tmpfs выделяется ПО ФАКТУ: значение — потолок, не резерв (0 Б до первого запроса). # Rollback = убрать строку (снова 64 МиБ) + пересоздать контейнер. shm_size: 1gb environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # Bind to 127.0.0.1 only — accessible from host (for SSH tunnel) but not from public internet. # UFW additionally blocks 5432 from outside. ports: - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data # GENDESIGN_PG_INITDB_DIR (#2989): та же мина initdb, что описана у trade-in # (см. tradein-mvp/docker-compose.prod.yml) — на СВЕЖЕМ томе postgres # прогоняет всё из этого каталога как initdb-миграции ДО восстановления # дампа Site Finder. На первом старте нового хоста задать переменную на # ПУСТОЙ каталог — схема тогда приезжает восстановлением дампа, а не # initdb-цепочкой; после restore переменную снять. # Дефолт = текущий прод-путь, поведение Beget не меняется. # # ⚠️ Postgres этого стека (Site Finder) НЕ параметризован по mem_limit/ # shared_buffers, в отличие от trade-in выше — сейчас у него этих лимитов # вовсе нет (стоковый shared_buffers=128MB на базе ~15 ГБ, wal_compression # off). Осознанно НЕ трогается в этой правке (#2989 — только initdb-мина, # добавление лимитов сервису без них — отдельная задача с отдельным риском # для текущего прода). См. описание PR. - ${GENDESIGN_PG_INITDB_DIR:-./backend/db/init}:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] interval: 5s timeout: 5s retries: 10 networks: default: {} shared: aliases: - gendesign-postgres redis: image: redis:7-alpine logging: *default-logging restart: unless-stopped volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 3s retries: 5 # #2709: redis вводится в gendesign_shared, чтобы tradein-backend вообще МОГ # его достать. До этого redis жил только в gendesign_default, а tradein — в # gendesign_shared + tradein-net: общей сети НЕТ, поэтому REDIS_URL там не # резолвился НИ ПОД КАКИМ именем. Это была не «забытая переменная», а # отсутствующая связность (см. #2709). # # Почему общий инстанс, а не свой redis в стеке trade-in: deploy-tradein.yml # поднимает стек как `up -d --no-deps $SERVICES`, где SERVICES — # ЗАХАРДКОЖЕННЫЙ список (browser backend frontend tgbot [scraper]). Новый # сервис в tradein-compose в этот список не попадает и `--no-deps` его не # подтянет → контейнер просто никогда бы не стартовал, а REDIS_URL указывал # бы в пустоту. Правка того списка = правка deploy-tradein.yml, который # сейчас заморожен (#2680 ждёт человека). Общий инстанс обходит это целиком. # # aliases: тот же приём, что уже применён к postgres выше — стабильное имя # gendesign-redis вместо compose-зависимого gendesign-redis-1. # ⚠️ `default` ОБЯЗАН быть перечислен явно: как только у сервиса появляется # блок networks:, неявная привязка к default пропадает, и backend/worker/ # beat/glitchtip потеряли бы брокер (та же грабля описана у postgres). # # Разделение ключей — по НОМЕРУ БД, инстанс общий: # db0 — gendesign (celery-брокер + кэши бэкенда), 2166 ключей # db1 — trade-in (SearchCache) ← вводится здесь # db2 — glitchtip (см. REDIS_URL ниже) # Ceiling: maxmemory=0 / noeviction на инстансе НЕ трогаем — allkeys-lru на # брокере celery вытеснял бы поставленные в очередь таски. Значит tradein # обязан ставить TTL на каждый ключ (он ставит: SET ... ex=ttl). Если # tradein когда-нибудь начнёт писать без TTL, упрётся весь инстанс, включая # celery. Тогда — отдельный инстанс, а не смена политики вытеснения. networks: default: {} shared: aliases: - gendesign-redis # OSRM routing engine (#39 — site-finder /analyze road/walking distances to POI # вместо straight-line ST_Distance). INFRA-only здесь: интеграция в /analyze — # отдельный follow-up (A2/A3). Backend ходит к нему по http://osrm:5000 через # default-сеть. НЕ публикуется наружу (нет ports:) — internal-only, Caddy его # не проксирует. # # Region: Свердловская обл (oblast-clip из Ural-FO extract). Префикс .osrm-файлов # задаётся OSRM_REGION (default `sverdlovsk`). Файлы /data/.osrm* кладёт # scripts/build_osrm.sh в ./data/osrm/ (host) → /data (container). Артефакты # .gitignored (не коммитим ~1.5GB build output) — на VPS строятся offline. # # --algorithm mld → pipeline extract→partition→customize (НЕ contract). max-table-size # поднят для будущих many-to-many table-запросов (изохроны A2). # # ВАЖНО: пока scripts/build_osrm.sh не отработал, /data/.osrm нет → # osrm-routed падает и контейнер crash-loop'ит (restart: unless-stopped). Это # БЕЗВРЕДНО для деплоя: backend НЕ depends_on osrm, поэтому `compose up -d` в # deploy.yml не блокируется. Оркестратор строит граф offline и поднимает osrm # отдельно (docs/osrm-routing.md). osrm: image: osrm/osrm-backend:latest logging: *default-logging restart: unless-stopped command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}.osrm volumes: - ./data/osrm:/data mem_limit: 1.5g # TCP-probe порта 5000 через bash /dev/tcp. osrm/osrm-backend (Debian-slim) # НЕ содержит curl/wget — HTTP-healthcheck падал бы на missing binary (ср. # glitchtip #644). bash в образе есть → /dev/tcp надёжно проверяет, что # osrm-routed слушает (= граф загружен в RAM, движок принимает запросы). # start_period щедрый: загрузка MLD-графа Свердл в RAM занимает время. # Полный route-probe (driving) — в scripts/build_osrm.sh и в PR-инструкции # для оркестратора (curl с хоста VPS, см. docs/osrm-routing.md). healthcheck: test: ["CMD-SHELL", "timeout 3 bash -c '- pg_isready -q -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}" && psql -U "$${POSTGRES_USER}" -d postgres -tAc "SELECT count(*) = 2 FROM pg_database WHERE datname IN ('forgejo', 'glitchtip')" | grep -qx t interval: 10s timeout: 5s retries: 10 # 20s хватало на «кластер поднялся»; теперь в старт входит ещё и прогон # initdb.d (создание двух баз из template0) — это доли секунды, но # запас до 30s снимает мигание unhealthy на холодном старте. start_period: 30s # ~1 ГБ данных против 15 ГБ у основного кластера (glitchtip 729 МБ + forgejo # 265 МБ). Считаем по-крупному: стоковый shared_buffers 128 МБ + ~25 # соединений (пул Forgejo + web/celery GlitchTip) по ~8 МБ + запас на # autovacuum и на восстановление дампа в окно миграции # (maintenance_work_mem 64 МБ) ≈ 400 МБ пика. 512 МБ даёт над этим воздух и # при этом на порядок меньше, чем съел бы оставленный на хосте postgis. # Ниже (256 МБ) опускать не стоит — OOM-kill пришёлся бы ровно на заливку # дампов, то есть на самый неудобный момент. mem_limit: 512m # shm_size НЕ поднимаем. У основного postgres 1 ГБ понадобился под DSM # параллельных планов по таблицам Site Finder (#2812); ни Forgejo, ни # GlitchTip на своих объёмах параллельных планов не строят — дефолтных # 64 МБ хватает с запасом. # # Портов НЕТ и на хост ничего не пробрасывается — сознательно. У основного # postgres есть `127.0.0.1:5432` ради SSH-туннеля к рабочей БД # (`ssh -N gendesign` → localhost:15432), здесь такой нужды нет: обе базы # обслуживаются своими приложениями по докер-сети, а разовая миграция и # разбор инцидентов идут через `docker exec gendesign-infra-postgres psql`. # Лишний слушающий сокет на хосте, где стоит ПУБЛИЧНЫЙ Forgejo, — чистый # прирост поверхности атаки без единого выигрыша. # # Сеть ровно одна и именно default (`gendesign_default`): в ней сидит # контейнер `forgejo` из отдельного стека /home/gendesign/forgejo, поэтому он # достучится сюда по имени `infra-postgres`. `default` перечислен ЯВНО — как # только у сервиса появляется блок networks:, неявная привязка к дефолтной # сети пропадает (та же грабля описана у postgres и redis выше). `shared` не # нужна: с trade-in этот кластер не разговаривает. networks: [default] glitchtip-web: image: glitchtip/glitchtip:6.1.6 container_name: glitchtip-web logging: *default-logging # profiles: ["glitchtip"] keeps this service from starting on plain `compose up -d`. # Bootstrap script activates the profile after DB + secrets are ready. # On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env. profiles: ["glitchtip"] # depends_on: БД здесь больше НЕТ, остался только redis (#3061). Зависимость # обязана быть верной в ОБЕИХ фазах переезда, а она статична — имя сервиса # нельзя вывести из GLITCHTIP_DB_HOST. Разобраны оба варианта: # * оставить `postgres: service_healthy` — после cutover этот сервис на # Beget не запускается вовсе (его том с Site Finder уехал), а depends_on # на незапущенный сервис НЕ игнорируется: compose поднимет postgres # вместе с glitchtip-web, то есть воскресит 15-гигабайтный postgis ровно # там, откуда его убирали; # * поставить `infra-postgres: service_healthy` — сделало бы доступность # работающего сегодня GlitchTip заложником кластера, который до cutover # ПУСТ и никем не используется: любая заминка с его паролями или # healthcheck'ом роняла бы трекер ошибок. Это прямо нарушает требование # «мерж и деплой ничего не меняют до переноса данных». # Терять при этом нечего: `service_healthy` у postgres доказывал лишь то, что # кластер принимает соединения, а не что база glitchtip и её миграции на # месте. Холодный старт с ещё не готовой БД GlitchTip переживает — контейнер # падает и поднимается заново по `restart: always` (ниже) с экспоненциальным # backoff'ом и сходится за десятки секунд. redis оставлен: он в дефолтном # профиле, присутствует в обеих фазах и нужен и брокеру celery, и веб-морде. depends_on: redis: condition: service_started environment: # GLITCHTIP_DB_HOST (#3061) — переключатель хоста БД на время переезда. # Дефолт `postgres` = СЕГОДНЯШНЕЕ поведение байт-в-байт, поэтому мерж этой # правки сам по себе ничего не меняет. Переключение на лёгкий кластер — # одна строка `GLITCHTIP_DB_HOST=infra-postgres` в /opt/gendesign/.env на # остающемся хосте, и только ПОСЛЕ того, как база glitchtip перенесена туда # дампом. Откат — убрать строку и перезапустить оба glitchtip-контейнера. # ⚠️ ПУСТОЕ значение переменной — НЕ то же самое, что «не задана»: # `GLITCHTIP_DB_HOST=` даст DSN вида postgres://glitchtip:pass@:5432/..., # то есть хост исчезнет из строки подключения и Django пойдёт в локальный # сокет. Подстановка `:-` срабатывает только для НЕЗАДАННОЙ переменной. Та # же грабля описана у CADDY_SITES в блоке caddy ниже: либо не задавать # вовсе, либо задавать имя сервиса. DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@${GLITCHTIP_DB_HOST:-postgres}:5432/glitchtip REDIS_URL: redis://redis:6379/2 SECRET_KEY: ${GLITCHTIP_SECRET} PORT: "8080" # Почта отключена по умолчанию: consolemail:// печатает письмо в stdout и # никуда его не отправляет. Реальный адрес приходит из /opt/gendesign/.env # (GLITCHTIP_EMAIL_URL) — в репозитории пароля почтового ящика быть не должно. # # ВАЖНО про схему DSN (django-environ, парсер GlitchTip): для порта 465 с # implicit SSL нужна схема smtp+ssl://, а НЕ smtps:// — вторая помечена # deprecated и включает STARTTLS (EMAIL_USE_TLS), то есть 465 с ней рвёт # соединение. Для 587/STARTTLS схема — smtp+tls://. # smtp+ssl://alerts%40meraocenka.ru:ПАРОЛЬ@smtp.beget.com:465 # Логин — почтовый адрес целиком, @ в нём кодируется как %40. EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://} GLITCHTIP_DOMAIN: https://errors.gendsgn.ru DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru} ENABLE_USER_REGISTRATION: "true" ENABLE_ORGANIZATION_CREATION: "false" # Встроенный MCP-сервер GlitchTip (появился в 6.1, у нас образ 6.1.6). # settings.py:291 — `GLITCHTIP_ENABLE_MCP = env.bool(..., False)`, то есть по # умолчанию ВЫКЛЮЧЕН, и https://errors.gendsgn.ru/mcp сейчас отдаёт 404 # (проверено curl'ом 29.08). Флаг читается в двух местах: asgi.py:181 # оборачивает ASGI-приложение в MCPDjangoDispatcher, api/api.py:189 цепляет # роуты. Отсюда — переменная нужна ТОЛЬКО web-контейнеру: worker крутит # celery и HTTP не отдаёт вовсе. # # ЗАЧЕМ. Заменяет неофициальный npm-пакет mcp-glitchtip (coffebar, 0.1.2 от # 25.08.2025, всего 2 инструмента) на вендорский с 17 инструментами, среди # которых detect_n_plus_one и get_transaction_trend. Четыре span-инструмента # требуют DuckDB и без него просто не отработают — остальные 13 не зависят. # # АУТЕНТИФИКАЦИЯ. Клиент может ходить статическим APIToken в заголовке # `Authorization: Bearer`, полный OAuth-флоу не обязателен: провайдер имеет # фолбэк (apps/oauth/provider.py:281-291 — «check regular API tokens ... for # MCP clients using Bearer auth instead of the full OAuth flow»), который # валидирует токен через apps/mcp/auth.py. Проверено чтением ИМЕННО образа # 6.1.6: апстрим-issue #473 про 401 на Bearer описывает более раннее # состояние кода и здесь уже неактуален. Минимальные scope токена — # org:read + project:read + event:read (apps/mcp/server.py, _check_scopes). # # BLAST RADIUS. Пересоздаётся только glitchtip-web (env — свойство создания # контейнера): единицы секунд без приёма событий, клиентские SDK буферизуют. # Схема БД и миграции не затрагиваются — это чистый флаг роутинга. # ОТКАТ: убрать строку, передеплоить; /mcp снова станет 404, а старый # npm-клиент продолжит работать через /api/0/ — он этим флагом не задет. GLITCHTIP_ENABLE_MCP: "true" restart: always mem_limit: 512m # Образ glitchtip:6.1.6 НЕ содержит wget/curl (только python3) → старый # wget-healthcheck падал на missing-binary → контейнер вечно unhealthy (#644). # Пробуем канонический /_health/ (GET → 200) через встроенный python3. healthcheck: test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/_health/', timeout=8).status == 200 else 1)"] interval: 30s timeout: 10s retries: 5 start_period: 60s networks: [default] glitchtip-worker: image: glitchtip/glitchtip:6.1.6 container_name: glitchtip-worker logging: *default-logging profiles: ["glitchtip"] # depends_on без БД — по тем же причинам, что у glitchtip-web выше (там же # разбор обоих отвергнутых вариантов). Для celery это ещё безопаснее: он и # так обязан переживать обрыв соединения с базой в рантайме, а не только на # старте. depends_on: redis: condition: service_started command: ./bin/run-celery-with-beat.sh environment: # Тот же переключатель GLITCHTIP_DB_HOST, что у glitchtip-web (полное # обоснование и грабля с ПУСТЫМ значением — в комментарии там). Значение # обязано совпадать с web: это один и тот же экземпляр базы, и разъехавшись, # они дадут веб-морду и воркер, читающих разные кластеры. DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@${GLITCHTIP_DB_HOST:-postgres}:5432/glitchtip REDIS_URL: redis://redis:6379/2 SECRET_KEY: ${GLITCHTIP_SECRET} CELERY_WORKER_AUTOSCALE: "1,3" # Письма и веб-хуки шлёт celery, то есть ИМЕННО этот контейнер, а не web. # До этой правки почтовых переменных здесь не было вовсе: настройка одного # glitchtip-web не дала бы ни одного отправленного письма — worker брал # умолчания образа. Значения обязаны совпадать с web (см. комментарий там). EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://} DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru} # Нужен для абсолютных ссылок внутри писем и веб-хуков: без него # уведомление приходит со ссылкой в никуда. GLITCHTIP_DOMAIN: https://errors.gendsgn.ru restart: always mem_limit: 384m # GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15, # см. tradein-mvp/backend/app/api/v1/glitchtip.py): вебхуки шлёт РЕАЛЬНО # этот celery-воркер (apps/alerts/webhooks.py send_webhook — не glitchtip-web), # получателю `webhook` нужен доступ к http://tradein-backend:8000/... — # tradein-backend сидит на gendesign_shared, у glitchtip-* её раньше не было # вообще (та же грабля, что #2709 у redis: сеть должна быть общей ДО того, # как переменная окружения с URL вообще имеет смысл). default — обязательно # явно, иначе воркер потеряет Postgres/Redis-брокер (см. комментарий у redis # выше про неявную привязку к default). networks: [default, shared] caddy: image: caddy:2 logging: *default-logging restart: unless-stopped ports: - "80:80" - "443:443" environment: # #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. Caddyfile # подставляет его в header_up X-Internal-Auth-Secret на /trade-in/* роутах. # Пустой дефолт = fail-open (backend не проверяет) → ничего не ломается до # провижининга. Значение задаётся руками в /opt/gendesign/.env на VPS. TRADEIN_INTERNAL_AUTH_SECRET: ${TRADEIN_INTERNAL_AUTH_SECRET:-} # #3059: какие site-блоки импортирует Caddyfile (`import # caddy/sites/{$CADDY_SITES:*}.caddy`). Плейсхолдер раскрывает САМ Caddy # из окружения СВОЕГО контейнера, а не compose из файла окружения хоста — # поэтому переменную обязательно пробрасывать сюда. Без этой строки # значение с хоста до Caddy не доходит и всегда работает дефолт `*`, # то есть оба файла сразу, а разделение хостов молча не срабатывает: # после переезда (#3057) Caddy на продовом хосте начал бы выпускать # сертификаты для obsidian/errors/git, чей DNS остаётся на # инфраструктурном хосте — HTTP-01 падает, а Let's Encrypt считает # неудачи (5 на домен в час). # Дефолт `*` = сегодняшнее поведение бит в бит: пока переменная не задана # ни на одном хосте, импортируются оба файла, как и до этой строки. # В окне: на продовом хосте CADDY_SITES=apps, на инфраструктурном=infra. CADDY_SITES: ${CADDY_SITES:-*} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy/users.caddy.snippet:/etc/caddy/caddy/users.caddy.snippet:ro # #3102 postmortem: сниппеты метрик импортируются из caddy/sites/infra.caddy # (`import ../metrics-*.caddy.snippet`). Смонтированы ПОФАЙЛОВО, как # users.caddy.snippet — сам каталог caddy/ внутрь не пробрасывается. # Забыть монт = Caddy не адаптирует конфиг и уходит в restart-loop, # роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru. - ./caddy/metrics-ingest.caddy.snippet:/etc/caddy/caddy/metrics-ingest.caddy.snippet:ro - ./caddy/metrics-ui.caddy.snippet:/etc/caddy/caddy/metrics-ui.caddy.snippet:ro # Untracked локальные site-блоки (см. import в конце Caddyfile). Каталог # держится в git через caddy/local/.gitignore — иначе docker создал бы # отсутствующий bind-source сам, root-owned пустышкой. - ./caddy/local:/etc/caddy/caddy/local:ro # Site-блоки, разложенные по хостам (#3059). Каддифайл импортирует их как # `import caddy/sites/{$CADDY_SITES:*}.caddy` — без переменной берутся оба # файла (текущее поведение Beget, все восемь доменов), CADDY_SITES=apps на # Selectel даёт пять уезжающих, =infra на Beget после переезда — три # остающихся (git / errors / obsidian). # ⚠️ ПУСТОЕ значение переменной — не то же самое, что «не задана»: Caddy # подставит пустую строку, получится путь caddy/sites/.caddy и конфиг не # соберётся. Либо не задавать вовсе, либо задавать apps/infra. - ./caddy/sites:/etc/caddy/caddy/sites:ro - ./preview:/srv/preview:ro - caddy_data:/data - caddy_config:/config - caddy_logs:/var/log/caddy # backend: service_healthy — backend имеет /health endpoint, готов до Caddy. # frontend: service_started — НЕ service_healthy: даже если frontend healthcheck # дребезжит, Caddy всё равно стартует (можно отдавать 502 для /, но # obsidian.gendsgn.ru, api.gendsgn.ru и т.д. остаются доступны). depends_on: backend: condition: service_healthy frontend: condition: service_started networks: - default # shared — для маршрута obsidian.gendsgn.ru → couchdb (отдельный stack). # Если obsidian-стек не задеплоен, Caddy просто получит 502 на этом маршруте, # main-приложение не страдает. - shared glitchtip-auth-forwarder: # Собирается локально на VPS при деплое (не тянется из GHCR). # deploy.yml запускает: docker compose build glitchtip-auth-forwarder build: ./ops/glitchtip-auth-forwarder container_name: gendesign-auth-forwarder logging: *default-logging restart: unless-stopped environment: GLITCHTIP_DSN: ${GLITCHTIP_DSN} APP_ENV: production APP_RELEASE: auth-forwarder-1 CADDY_LOG_FILE: /var/log/caddy/auth_audit.log STATE_FILE: /state/offset.json volumes: # Read-only доступ к Caddy access log - caddy_logs:/var/log/caddy:ro # Persistent offset — выживает при перезапуске контейнера - auth_forwarder_state:/state depends_on: - caddy volumes: postgres_data: # Данные лёгкого кластера остающейся инфраструктуры (#3061). Отдельный том, а # не соседство в postgres_data: тот уезжает на Selectel целиком, а этот обязан # остаться на Beget — 30.08.2026 их жизненные циклы расходятся навсегда. infra_postgres_data: redis_data: caddy_data: caddy_config: caddy_logs: auth_forwarder_state: networks: # Внешняя сеть, создаётся вне compose (см. docs/obsidian-livesync.md). # Связывает main-stack (Caddy) и obsidian-stack (CouchDB). shared: external: true name: gendesign_shared