gendesign/docker-compose.prod.yml
bot-backend 1b1977efa3
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m4s
CI / backend-tests (pull_request) Successful in 17m35s
fix(deploy): правки ревью гейта ПТИЦЫ — таймаут сессии, crash-loop, множественный id (#3324)
command_timeout: 30m — дефолт appleboy/ssh-action 10m короче worst-case гейта
(~17 мин), сессию убило бы посреди диагноза и авария читалась бы обрывом связи.

Деградация «нет health-конфига» требовала лишь running дважды: crash-loop с
временем жизни больше паузы проходил как стабильный (обе проверки видят running,
просто это разные жизни контейнера). Теперь сверяется RestartCount до/после окна
15 с; окно учитывается в счётчике ожидания, иначе таймаут 240 с растянулся бы на
~24 мин и упёрся в command_timeout.

cid(): `ps -aq` возвращает несколько id при залежавшемся exited-контейнере →
docker inspect падает → пустой статус → ложный красный. tail -n1.

worker healthcheck: убран `2>&1` (глушил причину, которую деплой печатает из
.State.Health.Log), retries 3→5 — при interval 60s тройка промахов = 3 минуты,
столько длится обычный флап Redis, а из unhealthy контейнер сам не выходит.
2026-09-02 16:58:40 +05:00

852 lines
63 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/<id>/ (умирает вместе с контейнером).
# Тот же 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=<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/<region>.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/<region>.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 '</dev/tcp/127.0.0.1/5000' || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
networks: [default]
# Пеший OSRM (#39 A3) — per-category routing: пешеходные POI (школа/магазин/парк/
# остановки) считаются по пешему графу, авто-POI (ТЦ/больница) — по `osrm` (car).
# Граф sverdlovsk-foot.osrm* собирается `CAR=0 WALK=1 bash scripts/build_osrm.sh`
# (или foot-only build_foot.sh). До сборки графа контейнер crash-loop'ит — безвредно
# (backend не depends_on, флаг use_osrm_distances OFF). Backend ходит к http://osrm-walk:5000.
osrm-walk:
image: osrm/osrm-backend:latest
logging: *default-logging
restart: unless-stopped
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm
volumes:
- ./data/osrm:/data
mem_limit: 1.5g
healthcheck:
test: ["CMD-SHELL", "timeout 3 bash -c '</dev/tcp/127.0.0.1/5000' || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
networks: [default]
backend:
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
# .env.runtime пишется deploy.yml через SSH (SENTRY_RELEASE=$IMAGE_TAG).
# required: false — compose не падает если файла нет (первый деплой).
env_file:
- path: ./backend/.env
- path: ./backend/.env.runtime
required: false
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "127.0.0.1:8000:8000"
volumes:
# RBAC roles config — single source of truth (auth/roles.yaml в репо).
# FastAPI middleware и /api/v1/me читают этот файл при старте (см.
# app/core/auth.py). Read-only — мутация только через PR.
- ./auth/roles.yaml:/app/auth/roles.yaml:ro
# Полные PDF-отчёты ПТИЦА (#2259 PR-D): worker ПИШЕТ файл сюда, backend его
# ЧИТАЕТ для /report/download → общий writable bind-mount на обоих сервисах.
- ./reports:/app/reports
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
# #976 cross-DB ETL tradein→gendesign: backend подключается к tradein-postgres
# через gendesign_shared network (tradein-postgres уже в этой сети).
# default — обязательно явно, иначе сервис выпадет из дефолтной сети.
networks: [default, shared]
frontend:
image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
depends_on:
- backend
# TCP-only probe через node (есть в alpine image, запускает сам server.js).
# НЕ использует HTTP semantics — проверяет только что порт 3000 слушает.
# Если Next.js процесс жив и порт открыт → healthy, независимо от HTTP status.
# start_period 60s даёт время Next.js standalone bundle bootstrap.
healthcheck:
test: ["CMD", "node", "-e", "require('net').createConnection({port:3000,host:'127.0.0.1'}).once('connect',function(){process.exit(0)}).once('error',function(){process.exit(1)})"]
interval: 15s
timeout: 5s
retries: 6
start_period: 60s
worker:
# Отдельный chromium-образ (+200 МБ Playwright). См. backend/Dockerfile target=runner-with-chromium.
image: ghcr.io/lekss361/gendesign-worker:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
env_file:
- path: ./backend/.env
- path: ./backend/.env.runtime
required: false
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./data:/app/data # playwright_state.json + photo binaries
# Полные PDF-отчёты ПТИЦА (#2259 PR-D): worker (build_full_report_task) ПИШЕТ
# PDF сюда, backend ЧИТАЕТ для /report/download → общий writable bind-mount.
- ./reports:/app/reports
# Read-only bind-mount Антоновского /sf/ SQLite — для objective_etl task.
# На VPS файл лежит в /opt/gendesign/site-finder/analysis.db; путь внутри
# контейнера задан settings.objective_anton_sqlite_path (default
# /data/anton-sqlite/analysis.db).
- /opt/gendesign/site-finder:/data/anton-sqlite:ro
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=8", "--queues=celery,scrape_kn,geo"]
# #3324: до этого у worker'а healthcheck'а не было ВООБЩЕ — контейнер в
# crash-loop'е (ImportError в новом коде, протухший uv.lock) уезжал зелёным
# деплоем: деплой смотрел только `curl backend /health`.
# Проба — `inspect ping` ИМЕННО В ЭТОТ узел (`-d celery@$(hostname)`, у нас
# nodename дефолтный: в command нет `-n`). Без `-d` ping вернул бы OK на
# ответ ЛЮБОГО воркера на брокере — мёртвый контейнер выглядел бы живым.
# Ответ на ping = жив parent-процесс и держится соединение с Redis, т.е.
# ровно та связность, без которой очереди не разбираются. `$$` — экранировка
# для compose (в контейнер уезжает литеральное `$(hostname)`).
# Интервал 60s (не 30s как у backend): каждая проба — отдельный запуск
# celery-CLI с импортом приложения, дешёвым его не назовёшь.
# start_period 90s: холодный старт worker'а с Chromium-образа заметно
# медленнее backend'а.
# stderr НЕ глушим: docker хранит вывод пробы в .State.Health.Log, и деплой
# печатает его в диагнозе — с `2>&1` там была бы пустота вместо причины.
# retries 5 (не 3): при interval 60s тройка промахов = 3 минуты, столько
# длится обычный флап Redis, а из unhealthy контейнер сам не выходит по
# restart-политике — следующий деплой краснел бы за исправный воркер.
healthcheck:
test: ["CMD-SHELL", "celery -A app.workers.celery_app inspect ping -d celery@$$(hostname) -t 10 >/dev/null"]
interval: 60s
timeout: 30s
retries: 5
start_period: 90s
# #976 cross-DB ETL tradein→gendesign: worker запускает etl_newbuilding_crossload task,
# которому нужен прямой TCP-доступ к tradein-postgres через gendesign_shared.
# default — обязательно явно, иначе сервис выпадет из дефолтной сети.
networks: [default, shared]
beat:
# Lean backend-образ (без Chromium) — beat только триггерит таски в Redis.
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
env_file:
- path: ./backend/.env
- path: ./backend/.env.runtime
required: false
depends_on:
redis:
condition: service_healthy
# --schedule=/tmp/...: default location `/app/celerybeat-schedule` падает
# с Permission denied — WORKDIR `/app` принадлежит root, `app` (uid 1000)
# не может создавать там файлы. `/tmp` всегда writable; schedule-файл
# хранит только last_run_at для periodic tasks — потеря на restart OK,
# beat перестроит из `celery_app.conf.beat_schedule` на старте.
command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info", "--schedule=/tmp/celerybeat-schedule"]
# #3324: beat тоже жил без healthcheck'а. На `inspect ping` beat НЕ отвечает
# (remote control — свойство воркера, не планировщика), поэтому проба другая:
# СВЕЖЕСТЬ shelve-файла расписания. Celery beat синкует его на диск не реже
# чем раз в `Scheduler.sync_every` = 180 с — но только когда в этом окне была
# отправлена задача. У нас в расписании есть поминутная (nspd-geo-zombie-
# cleanup, `* * * * *`) и двухминутная задачи, так что живой beat обновляет
# mtime примерно каждые 3 минуты, а вставший — не обновляет вовсе. Порог 10
# минут = 3× запас к этой каденции.
# Почему glob `celerybeat-schedule*`: имя на диске зависит от того, какой
# backend выберет shelve/dbm в образе (gnu → тот же файл, dumb → .dat/.dir).
# Почему не `pgrep`/`true`: PID-1 процесс жив ровно пока жив контейнер —
# такая проба повторяет `State.Running` и не ловит подвисший планировщик.
# `grep -q .`: сам find возвращает 0 и когда не нашёл ничего.
healthcheck:
test: ["CMD-SHELL", "find /tmp -maxdepth 1 -name 'celerybeat-schedule*' -mmin -10 | grep -q ."]
interval: 60s
timeout: 10s
retries: 3
start_period: 120s
# ── infra-postgres: лёгкий кластер ОСТАЮЩЕЙСЯ инфраструктуры (#3061) ────────
# Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.124.37.140),
# окно 30.08.2026. Контейнер `gendesign-postgres-1` уезжает целиком вместе с
# томом postgres_data (~15 ГБ) — а внутри него сегодня живут ЧЕТЫРЕ базы:
# gendesign 15 ГБ — Site Finder, уезжает
# auth 7.9 МБ — доступы «Меры»/«Птицы», уезжает с продуктом
# glitchtip 729 МБ — errors.gendsgn.ru, ОСТАЁТСЯ на Beget
# forgejo 265 МБ — git.gendsgn.ru + Actions CI, ОСТАЁТСЯ на Beget
# То есть в момент, когда postgres_data уедет, без базы останутся GlitchTip и
# Forgejo — а из Forgejo идёт сам деплой. Этот сервис — их новый дом.
#
# ПОЧЕМУ ОТДЕЛЬНЫЙ ЛЁГКИЙ КЛАСТЕР, А НЕ «оставить postgis на Beget». Держать
# на инфра-хосте postgis/postgis:16-3.4 с 15-гигабайтным томом ради ~1 ГБ
# реальных данных — это лишний диск, лишний RAM (у того сервиса ещё и
# shm_size: 1gb, #2812) и обновления PostGIS ради двух схем, которым геометрия
# не нужна вообще: forgejo (Go) и glitchtip (Django) — обычные реляционные
# схемы, ни geometry/geography, ни GIST по geom. Отсюда postgres:16-alpine —
# МАЖОРНАЯ ВЕРСИЯ ТА ЖЕ (16), значит дампы переносятся один-в-один, без
# апгрейда формата хранения.
#
# ПОРЯДОК ПЕРЕЕЗДА (все умолчания этого файла = сегодняшнее поведение, мерж и
# деплой сами по себе НИЧЕГО не переключают — сервис сидит в профиле `infra`,
# которого нет ни в одном .env):
# 1. дописать INFRA_PG_PASSWORD и FORGEJO_DB_PASS в /opt/gendesign/.env на
# Beget — сегодня их не читает никто, добавление безопасно. ОБА, а не
# один: initdb-скрипт падает на пустом любом из них, и падает уже ПОСЛЕ
# создания PGDATA, то есть повторно не выполнится никогда (см. мину
# initdb ниже и healthcheck, который такой полупустой кластер не пустит
# в healthy);
# 2. поднять пустой кластер: COMPOSE_PROFILES=glitchtip,infra в том же .env
# и `docker compose -p gendesign -f docker-compose.prod.yml up -d
# infra-postgres`. initdb заводит роли и ПУСТЫЕ базы. На прод это не
# влияет — в кластер ещё никто не ходит. Дождаться `healthy` в
# `docker ps`: до появления обеих баз healthcheck красный намеренно;
# 3. руками, в окно: pg_dump forgejo/glitchtip из gendesign-postgres-1 →
# psql ЦЕЛЕВОЙ РОЛЬЮ в gendesign-infra-postgres (почему именно ролью, а
# не суперюзером — в шапке ops/db-bootstrap/infra-postgres/01-*.sh).
# Этот шаг закрывает ops/split-infra-postgres.sh: гасит писателей, снимает
# дампы, заливает, сверяет. По умолчанию — СУХОЙ ПРОГОН, перенос только по
# --apply; прогнать сухой прогон стоит ЗАРАНЕЕ, засветло: он ловит забытый
# пароль в конфиге и расхождение версий до окна, а не в окне;
# 3a. СРАЗУ ЖЕ, тем же движением — прописать в конфиге бэкапа Forgejo
# (/etc/default/gendesign-backup-forgejo) `PG_CONTAINER=gendesign-postgres-1`.
# Не косметика: с момента заливки дампа непустая база forgejo лежит в
# ДВУХ запущенных контейнерах, а автоопределение в ops/backup-forgejo.sh
# на такую неоднозначность намеренно останавливается с exit 1 — то есть
# ночной бэкап git-хоста перестанет сниматься вовсе, и заметить это
# некому (канал оповещений check-backup-staleness.sh на этом хосте не
# настроен, см. ops/crontab-beget.cron). Пока боевой кластер старый —
# указываем старый; на шаге 4 меняем значение на gendesign-infra-postgres;
# после гашения старого кластера строку убираем совсем;
# 4. cutover: GLITCHTIP_DB_HOST=infra-postgres в /opt/gendesign/.env и
# `HOST = infra-postgres:5432` в app.ini Forgejo
# (/home/gendesign/forgejo/, его compose-файла в этом репозитории нет),
# затем перезапуск этих двух приложений и правка PG_CONTAINER из 3a.
# Откат = вернуть обе строки как было. Старый кластер к этому моменту не
# тронут: снятие дампов ничего в нём не меняет.
infra-postgres:
image: postgres:16-alpine
# Стабильное имя вместо compose-генерируемого `gendesign-infra-postgres-1`:
# ровно это имя перечислено в PG_CONTAINER_CANDIDATES у ops/backup-forgejo.sh
# (он ходит в БД через `docker exec "$PG_CONTAINER"` и выбирает контейнер по
# факту наличия данных, а не по зашитой константе — разбор в шапке того
# скрипта). Тот же приём уже применён у glitchtip-web, glitchtip-worker и
# gendesign-auth-forwarder.
container_name: gendesign-infra-postgres
logging: *default-logging
restart: unless-stopped
# РОВНО ОДИН профиль, и он НОВЫЙ — `infra`. Ни в одном /opt/gendesign/.env
# такого значения сегодня нет, поэтому мерж этой правки и любой следующий
# деплой для сервиса — честный no-op: compose его не видит, контейнер не
# создаётся, том не создаётся.
#
# ПОЧЕМУ НЕ `glitchtip`, хотя технически он подошёл бы. На Beget в
# /opt/gendesign/.env уже стоит COMPOSE_PROFILES=glitchtip (его выставляет
# scripts/bootstrap_glitchtip.sh), а docker-compose.prod.yml перечислен в
# paths: у .forgejo/workflows/deploy.yml, и шаг деплоя делает
# `set -a; source .env` и затем `docker compose -p gendesign up -d`. Общий
# профиль с glitchtip-web/worker означал бы, что кластер стартует на БОЕВОМ
# хосте в тот же час, когда PR влит, — то есть переключателем оказался бы
# сам мерж, что прямо противоречит требованию «все умолчания сохраняют
# сегодняшнее поведение». Хуже того, старт был бы СЛОМАННЫМ и МОЛЧАЛИВЫМ:
# INFRA_PG_PASSWORD в .env к тому моменту ещё нет, официальный энтрипойнт
# отказывается инициализировать кластер без пароля суперюзера, а
# `restart: unless-stopped` превращает это в бесконечный restart-loop. Деплой
# при этом зелёный — `up -d` не ждёт сервис, на который никто не depends_on,
# а health-check шага деплоя смотрит только на backend.
# Отдельный профиль переносит старт кластера туда, где ему и место: в шаг 2
# порядка переезда, то есть в правку одной переменной на VM
# (COMPOSE_PROFILES=glitchtip,infra), выполняемую осознанно и с уже
# заполненными паролями. Откат — убрать `,infra`.
#
# ГАРАНТИЯ «GlitchTip не поднимется без своей БД» от этого не теряется: до
# cutover он ходит в общий кластер `postgres` и к infra-postgres отношения
# не имеет, а после cutover COMPOSE_PROFILES на остающемся хосте уже
# содержит infra — иначе шага 4 просто не было бы.
#
# Профиль назван по ХОСТУ, а не по приложению, потому что главный
# потребитель этого кластера — Forgejo, которого в данном compose нет вовсе
# (живёт отдельным стеком в /home/gendesign/forgejo, см. шапку
# ops/backup-forgejo.sh): привязать «БД для Forgejo» через depends_on не к
# чему. Если GlitchTip когда-нибудь выключат, COMPOSE_PROFILES=infra всё
# равно поднимет кластер — git.gendsgn.ru не должен зависеть от судьбы
# трекера ошибок.
# На Selectel профиль `infra` не активируется никогда (и GlitchTip, и Forgejo
# остаются на Beget) → сервис там не стартует и тома не создаёт.
profiles: ["infra"]
environment:
# Суперюзерская пара САМОГО кластера — это НЕ роли forgejo/glitchtip.
# Ею initdb-скрипт заводит прикладные роли и базы, ею же удобно ходить
# `docker exec`-ом в окно миграции и при разборе инцидентов.
# У пароля намеренно НЕТ дефолта-заглушки: с пустым INFRA_PG_PASSWORD
# официальный образ откажется инициализировать кластер с внятным
# "Database is uninitialized and superuser password is not specified",
# тогда как тихий `changeme` на хосте с публичным Forgejo — это дыра,
# которую никто не заметит.
POSTGRES_DB: ${INFRA_PG_DB:-infra}
POSTGRES_USER: ${INFRA_PG_USER:-infra}
POSTGRES_PASSWORD: ${INFRA_PG_PASSWORD}
# Читаются ТОЛЬКО initdb-скриптом (ops/db-bootstrap/infra-postgres/
# 01-roles-and-databases.sh) — он создаёт ими роли-владельцы баз.
# GLITCHTIP_DB_PASS переиспользуется как есть, новая переменная не
# заводится: это тот же пароль, что уже стоит в DATABASE_URL у
# glitchtip-web/worker, и после cutover он ОБЯЗАН совпадать — иначе
# GlitchTip не залогинится в перенесённую базу.
FORGEJO_DB_PASS: ${FORGEJO_DB_PASS}
GLITCHTIP_DB_PASS: ${GLITCHTIP_DB_PASS}
volumes:
- infra_postgres_data:/var/lib/postgresql/data
# ⚠️ ТА ЖЕ МИНА initdb, что описана у сервиса postgres выше (#2989) и в
# tradein-mvp/docker-compose.prod.yml: каталог /docker-entrypoint-initdb.d
# исполняется ТОЛЬКО на ПУСТОМ томе и ТОЛЬКО один раз — на самом первом
# старте, до того как в кластер попадут любые данные.
# Здесь это ровно то, что нужно: сначала initdb создаёт роли и пустые базы,
# ПОТОМ руками заливаются дампы (роли обязаны существовать до
# восстановления — дампы сняты с --no-owner --clean --if-exists).
# Обратная сторона та же: если том infra_postgres_data уже не пуст, скрипты
# МОЛЧА не выполнятся — ни ошибки, ни строчки в логе, и правка скрипта
# после первого старта сама не применится. Прогнать заново =
# `docker volume rm gendesign_infra_postgres_data` (кластер пересоздастся
# с нуля) либо выполнить те же команды psql руками.
- ./ops/db-bootstrap/infra-postgres:/docker-entrypoint-initdb.d:ro
# Healthcheck проверяет НЕ ТОЛЬКО «кластер принимает соединения», но и что
# обе базы на месте — и это главное здесь, а не перестраховка.
#
# ЗАЧЕМ. Мина initdb выше имеет злую разновидность: ОТРАВЛЕННЫЙ ТОМ. Порядок
# шагов официального энтрипойнта — сначала initdb создаёт PGDATA (пишет
# PG_VERSION), и ТОЛЬКО ПОТОМ исполняется /docker-entrypoint-initdb.d.
# Значит любое падение bootstrap-скрипта (проще всего — забыть один из двух
# паролей в .env: FORGEJO_DB_PASS новый, а GLITCHTIP_DB_PASS там уже есть,
# так что «половина» выглядит совершенно правдоподобно) роняет контейнер
# УЖЕ ПОСЛЕ инициализации тома. На следующем рестарте энтрипойнт видит
# непустой PGDATA, объявляет DATABASE_ALREADY_EXISTS и пропускает
# initdb.d НАВСЕГДА — кластер поднимается, но ролей forgejo/glitchtip и их
# баз в нём нет и больше не появится.
# Один `pg_isready` такой кластер от готового не отличает: он зелёный,
# `docker ps` показывает healthy, и обнаружилось бы это в ночь переезда, на
# заливке дампа, репликой `role "forgejo" does not exist` — в окно, когда
# разбираться уже некогда. Проверка наличия обеих баз делает отравленный том
# видимым сразу и в самом обычном месте — в колонке STATUS у `docker ps`.
# Лечение то же: `docker volume rm gendesign_infra_postgres_data` и поднять
# заново с заполненными паролями.
#
# Идём в служебную базу `postgres`, а не в ${POSTGRES_DB}: pg_database
# виден из любой базы, а лишняя привязка к имени тут ни к чему. `-tA` даёт
# голое `t`/`f` без рамок и пробелов, `grep -qx t` не даст `f` пролезть как
# непустой вывод. Проверка остаётся зелёной и после переезда: базы никуда не
# деваются, а восстановление дампа с `--clean --if-exists` дропает объекты
# ВНУТРИ базы, но не саму базу.
healthcheck:
test:
- CMD-SHELL
- >-
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