Два файла и две команды разворачивания заменены одним docker-compose.yml. postgres/redis перенесены туда же за профилем local-db — не поднимаются по умолчанию (app по умолчанию идёт на внешние DB_HOST/REDIS_HOST из .env), включаются явно через --profile local-db up -d. Проверено `docker compose config` в обоих режимах. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
api-copp
Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на React (SPA). Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. Тест можно пройти анонимно — достаточно выбрать школу и класс, аккаунт не нужен.
Содержание
- Архитектура
- Быстрый старт
- Переменные окружения
- Структура проекта
- База данных
- API
- Перенос данных между серверами
- Паутинная диаграмма
- Redis и кэширование
- Деплой с внешним прокси
- Известные ошибки и решения
Архитектура
Браузер пользователя
│ :80
[ web-react — nginx (SPA + reverse-proxy /api) ] ← единственный публичный сервис
│ http://api:8000 (только Docker-сеть)
[ api — FastAPI :8000 ]
│ │
[ PostgreSQL :5432 ] [ Redis :6379 ]
Сервис api не публикует порты наружу — доступен только контейнеру web-react внутри Docker-сети. Браузер обращается к /api/*, nginx внутри web-react перенаписывает и проксирует запрос на api:8000 (см. web-react/nginx.conf). Пользователь никогда не обращается к API напрямую.
PostgreSQL и Redis могут запускаться как:
- сервисами
postgres/redisв том жеdocker-compose.yml, за профилемlocal-db(локально в Docker) - внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные
REDIS_HOST/DB_HOSTв.env, профильlocal-dbне поднимать
Быстрый старт
Один файл docker-compose.yml на всё. По умолчанию поднимает только приложение (api + web-react + stats) — БД и Redis ожидаются внешними (DB_HOST/REDIS_HOST в .env, см. «Переменные окружения»).
# Только приложение (внешние БД/Redis из .env)
docker compose up -d --build
# + локальные PostgreSQL и Redis в Docker (профиль local-db)
docker compose --profile local-db up -d --build
| Адрес | Что |
|---|---|
http://localhost |
Веб-интерфейс (React SPA) |
http://localhost:8000/docs |
Swagger UI (HTTP Basic Auth) |
http://localhost:8000/redoc |
ReDoc |
http://localhost:8081 |
Дашборд статистики |
Реальные значения переменных окружения (пароли, ключи) держите в
.envв корне репозитория —docker composeподставляет их автоматически. Шаблон без секретов — в.env.example. Файл.envв.gitignore, в репозиторий не попадает.
3. Инициализация БД (первый запуск)
# Создать таблицы
curl -X POST http://localhost:8000/db/create-tables \
-H "X-Admin-Key: <ADMIN_KEY>"
# Применить все миграции (безопасно запускать повторно — IF NOT EXISTS)
curl -X POST http://localhost:8000/db/migrate \
-H "X-Admin-Key: <ADMIN_KEY>"
# Миграция FK: responses.user_id → users.id ON DELETE CASCADE
curl -X POST http://localhost:8000/db/migrate-cascade-user \
-H "X-Admin-Key: <ADMIN_KEY>"
Важно:
db/migrate-cascade-userнужно выполнить один раз на каждом сервере с уже существующей БД (созданной до апреля 2026). На новых базах — уже включено автоматически черезcreate-tables.
4. Загрузка тестов
# Голланд (42 пары профессий)
curl -X POST http://localhost:8000/polls/holland -H "X-Admin-Key: <ADMIN_KEY>"
curl -X POST http://localhost:8000/polls/holland/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"
# Климов (ДДО)
curl -X POST http://localhost:8000/polls/klimov -H "X-Admin-Key: <ADMIN_KEY>"
curl -X POST http://localhost:8000/polls/klimov/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"
# Гломшток (Карта интересов)
curl -X POST http://localhost:8000/polls/glomshtok -H "X-Admin-Key: <ADMIN_KEY>"
curl -X POST http://localhost:8000/polls/glomshtok/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"
Переменные окружения
docker-compose.ymlчитает значения из.env(не коммитится, см..env.exampleза шаблоном). Перед продакшн-деплоем обязательно смените все пароли и ключи в своём.env.
| Переменная | Описание | Пример |
|---|---|---|
APP_PATH |
Путь к директории приложения внутри контейнера | /app |
DEBUG |
Режим отладки API | False |
DB_HOST |
Хост PostgreSQL | postgres |
DB_PORT |
Порт PostgreSQL | 5432 |
DB_USER |
Пользователь БД | postgres |
DB_PASS |
Пароль БД | — |
DB_NAME |
Имя базы данных | profi |
REDIS_HOST |
Хост Redis | redis |
REDIS_PORT |
Порт Redis | 6379 |
REDIS_DB |
Номер БД Redis | 0 |
REDIS_PASSWORD |
Пароль Redis | — |
SECRET_KEY |
Секрет JWT (min 32 символа) | python -c "import secrets; print(secrets.token_hex(32))" |
ADMIN_KEY |
Ключ для admin-эндпоинтов (X-Admin-Key) |
— |
DOCS_USERNAME |
Логин для /docs и /redoc |
admin |
DOCS_PASSWORD |
Пароль для /docs и /redoc |
— |
API_URL |
URL API для фронтенда (только Docker-сеть) | http://api:8000 |
Структура проекта
api-copp/
├── main.py # точка входа FastAPI, регистрация роутеров
├── pyproject.toml / uv.lock # зависимости API (единственный источник — читает Dockerfile.api через uv)
├── .dockerignore # что не попадает в build context образов
├── .env.example # шаблон переменных окружения без секретов
├── Dockerfile.api # образ API
├── Dockerfile.stats # образ дашборда статистики
├── docker-compose.yml # api + web-react + stats (+ postgres/redis за профилем local-db)
│
├── bd/
│ ├── __init__.py # Settings (pydantic), make_engine()
│ └── tables/
│ ├── simple_base.py
│ ├── users.py # User (username, hashed_password, group_id, org_id)
│ ├── organization.py # Organization
│ ├── group.py # Group
│ ├── poll.py # Poll (title, description, is_active)
│ ├── question.py # Question (type: single/multiple)
│ ├── choice.py # Choice (варианты ответов)
│ ├── response.py # Response, Answer
│ ├── scale.py # ScaleDimension, ChoiceScore
│ └── radar_result.py # RadarResult, RadarResultItem
│
├── route/
│ ├── auth_utils.py # JWT, bcrypt, require_admin_key
│ ├── auth.py # /auth/register, /auth/login, /auth/logout
│ ├── base.py # /health, /version
│ ├── init_data_base.py # /db/create-tables, /db/migrate, /db/migrate-cascade-user
│ ├── users_crud.py # CRUD пользователей
│ ├── groups_crud.py # CRUD групп
│ ├── organizations_crud.py # CRUD организаций
│ ├── poll_crud.py # CRUD опросов
│ ├── question_crud.py # CRUD вопросов
│ ├── choice_crud.py # CRUD вариантов ответов
│ ├── response_crud.py # приём ответов + авто-расчёт радара
│ ├── holland_crud.py # тест Голланда
│ ├── klimov_crud.py # тест Климова (ДДО)
│ ├── glomshtok_crud.py # тест Гломштока (Карта интересов)
│ ├── scale_crud.py # CRUD осей и расшифровок
│ ├── radar_crud.py # расчёт и чтение радарных результатов
│ ├── radar_svg_gen.py # серверная генерация SVG-диаграмм
│ ├── stats_crud.py # статистика (кэшируется в Redis, TTL 5 мин)
│ └── transfer_crud.py # экспорт/импорт/бэкап/восстановление БД
│
├── web-react/ # React SPA-фронтенд (порт 80, свой Dockerfile+nginx)
│ ├── src/api/client.ts # единая точка HTTP-запросов к API
│ ├── src/auth/AuthContext.tsx # токен-логин + анонимная сессия (школа+класс без аккаунта)
│ ├── src/pages/ # страницы (Login, AnonymousEntry, Poll, MyTests, ...)
│ └── nginx.conf # раздаёт статику SPA, проксирует /api → api:8000
│
├── stats/ # отдельный дашборд статистики (Flet, порт 8081)
│
└── data/ # том Docker (./data:/app/data)
├── postgres/ # данные PostgreSQL
└── radar/ # SVG-файлы диаграмм (структура: <тест>/<группа>/<user_id>.svg)
База данных
Схема связей
organizations ──< groups ──< users
│ CASCADE DELETE
polls ──< questions ──< choices ──< choice_scores
│
└──< responses ──< answers (CASCADE: poll→responses, responses→answers)
│
└──< radar_results ──< radar_result_items (CASCADE)
│
image_path → data/radar/...svg
polls ──< scale_dimensions ──< choice_scores
│
└── radar_result_items.dimension_id (SET NULL при удалении оси)
Каскадное удаление
При удалении пользователя автоматически удаляется:
- все его
responses→ всеanswersвнутри → всеradar_results→ всеradar_result_items
organization и group, к которым был привязан пользователь, не удаляются — только обнуляются ссылки (SET NULL).
При удалении теста (poll):
- все
questions→ всеchoices→ всеchoice_scores - все
responses→ всеanswers→ всеradar_results→ всеradar_result_items - все
scale_dimensions→ всеchoice_scores
Миграции
Применяются через admin-эндпоинты (безопасно запускать повторно — используют IF NOT EXISTS/IF EXISTS):
| Эндпоинт | Что делает |
|---|---|
POST /db/create-tables |
Создаёт все таблицы по моделям SQLAlchemy |
POST /db/migrate |
Добавляет колонки username/hashed_password в users, image_path в radar_results |
POST /db/migrate-cascade-user |
Добавляет FK responses.user_id → users(id) ON DELETE CASCADE |
API
Аутентификация
POST /auth/register — регистрация (username ≥ 3 симв., password ≥ 8 симв.)
POST /auth/login — JWT-токен (OAuth2 password flow)
POST /auth/logout — отзыв токена (добавляет в Redis-блэклист)
Токен: Authorization: Bearer <token>
Admin-операции: X-Admin-Key: <ADMIN_KEY>
Тесты и вопросы
GET /polls — список тестов
POST /polls — создать тест [admin]
GET /polls/{id} — тест с вопросами и вариантами
DELETE /polls/{id} — удалить тест [admin]
POST /polls/{id}/questions — добавить вопрос [admin]
POST /questions/{id}/choices — добавить вариант ответа [admin]
Прохождение теста
POST /polls/{poll_id}/responses — сдать тест (автоматически считает радар + генерирует SVG)
GET /polls/users/{user_id}/responses — история пользователя
GET /responses/{id} — конкретный результат с ответами
POST /polls/{poll_id}/responses не требует авторизации: user_id необязателен. web-react использует это для анонимного прохождения — школьник на /anonymous выбирает только школу и класс (без пароля и аккаунта), выбор кладётся в group_id/organization_id тела запроса вместо user_id. Результат сохраняется в базе как снимок школы/класса без привязки к пользователю; истории результатов после закрытия вкладки у анонимной сессии нет — только у настоящих аккаунтов (/my-tests).
Результаты (паутинная диаграмма)
GET /responses/{id}/radar — данные диаграммы (оси + баллы)
GET /responses/{id}/radar/image — SVG-файл диаграммы
POST /responses/{id}/radar/compute — пересчитать диаграмму [admin]
GET /users/{user_id}/radar-results — история всех радарных результатов пользователя
Оси и расшифровки
POST /polls/{poll_id}/dimensions — создать ось [admin]
GET /polls/{poll_id}/dimensions — список осей
PUT /dimensions/{id} [admin]
DELETE /dimensions/{id} [admin]
POST /dimensions/{id}/scores — задать расшифровку варианта ответа [admin]
GET /choices/{choice_id}/scores
Готовые тесты
POST /polls/holland — создать тест Голланда (42 пары) [admin]
POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки [admin]
POST /polls/klimov — создать тест Климова (ДДО) [admin]
POST /polls/klimov/{poll_id}/seed-scores [admin]
POST /polls/glomshtok — создать тест Гломштока [admin]
POST /polls/glomshtok/{poll_id}/seed-scores [admin]
Пользователи / Группы / Организации
POST /users/ — создать пользователя [admin]
GET /users/ — список пользователей [admin]
DELETE /users/{id} — удалить пользователя (каскадно удаляет все данные)
GET /users/me — текущий пользователь
PUT /users/me — обновить профиль
GET /groups/ — список групп
POST /groups/ [admin]
...
GET /organizations/ — список организаций
POST /organizations/ [admin]
...
Статистика
GET /stats/summary — общая сводка (total_responses, total_polls, ...)
GET /stats/responses?group_by=poll — ответы по тестам/организациям/группам/месяцам
GET /stats/results/avg-by-dimension — средний балл по осям
GET /stats/results/distribution — распределение баллов
GET /stats/years — список лет с активностью
Фильтры: poll_id, org_id, group_id, year. Все ответы кэшируются в Redis (TTL 5 минут). Кэш автоматически сбрасывается при удалении пользователя.
База данных
POST /db/create-tables [admin]
POST /db/migrate [admin]
POST /db/migrate-cascade-user [admin]
Служебные
GET /health
GET /version
Перенос данных между серверами
Два сценария использования:
Сценарий 1: Дополнение (transfer)
Ученики прошли тест на сервере A — нужно перенести их результаты на сервер B, не удаляя существующие данные на B.
# Сервер A — скачать архив (все таблицы + SVG-диаграммы)
curl -H "X-Admin-Key: <KEY_A>" \
http://server-a:8000/transfer/export -o export.zip
# Сервер B — на новой БД сначала создать схему (один раз)
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/create-tables
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/migrate
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/migrate-cascade-user
# Сервер B — импортировать (добавляет только новое, дубли по UUID пропускаются)
curl -X POST -H "X-Admin-Key: <KEY_B>" \
-F "file=@export.zip" \
http://server-b:8000/transfer/import
Ответ импорта:
{
"status": "ok",
"result": {
"inserted": {"organizations": 107, "users": 4, "responses": 34, "radar_results": 34, "svg_files": 23, ...},
"skipped": {"organizations": 0, "users": 0, "responses": 0, "radar_results": 0, ...}
}
}
При повторном импорте того же архива все записи попадают в skipped — дублей не создаётся.
Сценарий 2: Резервное копирование и восстановление (backup)
Полный снимок БД с возможностью полного восстановления.
# Создать резервную копию (имя файла начинается с backup_)
curl -H "X-Admin-Key: <KEY>" \
http://localhost:8000/backup/export -o backup_20260410.zip
# Восстановить из резервной копии (УДАЛЯЕТ все текущие данные, вставляет из архива)
curl -X POST -H "X-Admin-Key: <KEY>" \
-F "file=@backup_20260410.zip" \
http://localhost:8000/backup/restore
Внимание:
/backup/restoreполностью очищает БД (в обратном FK-порядке) перед вставкой данных. SVG-папка тоже очищается и восстанавливается. Используйте только для полного восстановления.
Ответ восстановления:
{
"status": "ok",
"deleted": {"radar_result_items": 332, "users": 4, "organizations": 107, ...},
"inserted": {"organizations": 107, "users": 4, "radar_result_items": 332, "svg_files": 23, ...}
}
Что входит в архив
Оба архива содержат одинаковый набор данных (12 таблиц в FK-порядке + SVG):
manifest.json ← версия формата, дата, количество записей
organizations.json
groups.json
polls.json
questions.json
choices.json
scale_dimensions.json
choice_scores.json
users.json
responses.json
answers.json
radar_results.json
radar_result_items.json
radar_svgs/ ← все SVG-файлы (структура папок сохраняется)
Все UUID сохраняются без изменений — FK-связи не рвутся.
Паутинная диаграмма
Как работает
При сдаче теста (POST /polls/{id}/responses) сервер автоматически:
- Суммирует баллы по каждой оси (
ChoiceScore.score) для выбранных вариантов - Сохраняет
radar_result+radar_result_items - Генерирует SVG и сохраняет в
{APP_PATH}/data/radar/<тест>/<группа>/<user_id>.svg - Записывает путь в
image_path— всё в одномsession.commit()
SVG-файл
Генерируется чистым Python (route/radar_svg_gen.py):
- Фон
#2F184B, полигонrgba(155,114,207,0.55) - 5 концентрических сеток
- Подписи с именем типа и баллом
- Размер: 520×520 пикселей
Скачивание из браузера
web-react отдаёт SVG через тот же same-origin прокси, что и остальной API: <img src="/api/responses/{id}/radar/image">, nginx внутри контейнера web-react перенаправляет запрос на api:8000 (см. web-react/nginx.conf). Браузер по-прежнему не обращается к api напрямую — api не публикует порты.
Redis и кэширование
Настройка Redis
Используется connection pool (max_connections=20). При Redis с отключённой командой CONFIG (например, Debian redis-server по умолчанию) — настройки меняются только через /etc/redis/redis.conf + systemctl restart redis.
Рекомендуемые настройки для продакшна:
maxmemory 2048mb
maxmemory-policy volatile-lru
appendfsync everysec
timeout 300
tcp-keepalive 60
Назначение ключей
| Префикс | Что хранит | TTL |
|---|---|---|
token_blacklist:<jti> |
Отозванные JWT-токены | До истечения токена |
cache:<md5> |
Кэш ответов /stats/* |
5 минут |
Сброс кэша
Кэш статистики (cache:*) автоматически сбрасывается при удалении пользователя — чтобы счётчики обновились немедленно, а не через 5 минут.
Тест Голланда
Модификация Г.В. Резапкиной теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре.
| Тип | Название | Цвет |
|---|---|---|
| Р | Реалистичный | #FF8C00 |
| И | Интеллектуальный | #4169E1 |
| С | Социальный | #32CD32 |
| К | Конвенциональный | #9370DB |
| П | Предприимчивый | #DC143C |
| А | Артистический | #FF69B4 |
Каждый тип — 14 вопросов (14 максимальных баллов). seed-scores создаёт 6 осей (ScaleDimension) и 84 записи расшифровки (ChoiceScore).
Деплой с внешним прокси
web-react — статический SPA + reverse-proxy внутри своего же контейнера (web-react/nginx.conf), долгоживущих WebSocket-соединений (как было у Flet) больше нет. Минимальная конфигурация внешнего nginx перед контейнером:
location / {
proxy_pass http://<server-ip>:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
}
⚠
nginx.confиnginx.production.confв репозитории всё ещё написаны под старый Flet-фронтенд (upstream flet_app, отдельныйlocation /wsдля WebSocket,location /radar-image/). После перехода наweb-reactэти файлы не актуальны и не обновлялись —nginx.production.conf, судя по всему, конфиг боевого внешнего nginx дляtest.cloud-copp74.ru, поэтому его правка не входит в эту уборку без отдельного подтверждения (ошибка здесь ломает публичный домен). Если используете внешний прокси в проде — сверьте его с примером выше перед следующим деплоем.
Известные ошибки и решения
1. api недоступен из браузера ✅ (относилось к старому Flet-фронтенду)
Проблема: Docker-имя api не резолвится у браузера пользователя. Любой URL вида http://api:8000/... в page.launch_url() (Flet) давал ошибку DNS.
Решение в web-react: same-origin прокси /api/* в web-react/nginx.conf — браузер всегда обращается к тому же origin, что и отдал SPA, прямых обращений к api из браузера нет в принципе (см. «Скачивание из браузера» выше).
2. SVG записывался без image_path при сбое ✅
Проблема: image_path обновлялся вторым session.commit() — при падении между первым и вторым записи оставались без пути к файлу.
Решение: SVG генерируется до коммита, image_path устанавливается до коммита. Один атомарный commit().
3. /docs был открыт без авторизации ✅
Решение: docs_url=None, openapi_url=None, redoc_url=None — стандартные маршруты отключены. Вместо них собственные маршруты с HTTP Basic Auth (secrets.compare_digest против timing-атак).
4. Варианты ответов обрезались ✅ (относилось к старому Flet-фронтенду)
Проблема: ft.Radio(label=...) в Flet не переносит текст метки — длинные варианты обрезались.
Решение: актуально не для этого проекта — в web-react варианты ответа рендерятся обычной HTML-разметкой (PollPage.tsx), перенос текста работает из коробки.
5. image_path требует миграции на старых БД ✅
Решение:
ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512);
Применяется через POST /db/migrate.
6. При удалении пользователя оставались его ответы ✅
Проблема: responses.user_id был простым UUID без FK-ограничения. При удалении пользователя его ответы, answers и radar_results оставались в БД (висящие строки).
Решение: Добавлен FK responses.user_id → users(id) ON DELETE CASCADE. Теперь удаление пользователя каскадно удаляет все его данные.
Применяется через POST /db/migrate-cascade-user.
7. Статистика не обновлялась после удаления пользователя ✅
Проблема: После удаления пользователя кэш /stats/* в Redis ещё 5 минут отдавал старые данные (включая удалённые ответы).
Решение: delete_user вызывает cache_flush() — удаляет все ключи cache:* через scan_iter. Следующий запрос к статистике сразу идёт в БД.
8. Redis на Debian отключает команду CONFIG ✅
Проблема: /etc/redis/redis.conf содержал rename-command CONFIG "" — команда CONFIG SET возвращала ошибку ERR unknown command.
Решение: Настройки меняются напрямую в /etc/redis/redis.conf + systemctl restart redis. Строку rename-command CONFIG "" при необходимости удалить или закомментировать.
9. pg8000 вместо psycopg ✅
Проблема: pyproject.toml указывал psycopg>=3.3.3, реально использовался pg8000.
Решение: Зависимость заменена на pg8000>=1.29.0. Все запросы к БД синхронные.
10. Дублирование SVG-генератора ✅
Было: route/radar_svg_gen.py (сервер) и web/radar_svg.py (клиент, Flet) — два разных генератора, второй нигде не вызывался.
Решение: старый Flet-фронтенд web/ целиком удалён вместе с Dockerfile.web (заменён на web-react в коммите eac6192). Единственный генератор SVG теперь — route/radar_svg_gen.py.
11. Реальные секреты в docker-compose.yml ✅
Проблема: пароли БД/Redis, SECRET_KEY, ADMIN_KEY были прописаны в открытом виде прямо в docker-compose.yml (тогда ещё вместе с отдельным docker-compose_db.yml), закоммичены в git.
Решение: значения вынесены в .env (в .gitignore, не коммитится), compose-файлы читают их через ${VAR}. Шаблон без секретов — .env.example. Важно: старые значения уже есть в истории git (коммит 71887b0) — сама по себе эта правка их не отзывает, реальные пароли на серверах БД/Redis нужно ротировать отдельно.
12. Зависимости API расходились между pyproject.toml и requirements.txt ✅
Проблема: Dockerfile.api ставил зависимости из requirements.txt, а не из pyproject.toml/uv.lock — версии успели разойтись (redis==5.0.0 vs >=7.1.0, sqlalchemy==2.0.32 vs >=2.0.47).
Решение: Dockerfile.api теперь ставит зависимости через uv sync --frozen напрямую из pyproject.toml/uv.lock. requirements.txt удалён — единственный источник правды. При переходе обнаружилась ещё одна расхождение: python-multipart (нужен FastAPI для UploadFile/Form, используется в organizations_crud.py при импорте .docx) был в requirements.txt, но отсутствовал в pyproject.toml — без него API падал при старте с RuntimeError. Добавлен в зависимости.
13. Деплой требовал двух отдельных compose-файлов ✅
Было: docker-compose.yml (api + web-react + stats) и docker-compose_db.yml (postgres + redis) — два отдельных файла и две команды на разворачивание.
Решение: postgres/redis перенесены в тот же docker-compose.yml, за профилем local-db — не поднимаются командой docker compose up -d по умолчанию (приложение по умолчанию ходит на внешние DB_HOST/REDIS_HOST из .env), но доступны через docker compose --profile local-db up -d, когда нужна локальная БД. docker-compose_db.yml удалён.