Постоянный домен заменяет туннель. Всё решение живёт на одном адресе:
| Адрес | Что отдаёт |
|---|---|
https://my-olymp.ru/ |
мини-приложение, статика из frontend/ |
https://my-olymp.ru/api/* |
API, авторизация по подписи данных запуска |
https://my-olymp.ru/webhook |
события бота от платформы MAX |
https://my-olymp.ru/health |
проверка живости |
Один origin на фронтенд и API — это не только удобство: браузер не делает
preflight, CORS в бою не участвует вовсе, а заголовок-костыль
ngrok-skip-browser-warning больше не нужен и удалён.
Docker с Compose v2 и git. На чистой Ubuntu или Debian — две команды:
curl -fsSL https://get.docker.com | sudo sh
sudo apt-get install -y git
Проверить: docker compose version отвечает v2.24.0 или новее
(на более старых локальный docker-compose.yml не проходит проверку),
а docker info --format '' — linux;
my-olymp.ru на IP этого сервера — см. шаг 0. Если заводите ещё
и www, A-запись для него тоже должна существовать до выпуска
сертификата.Домен и сервер могут быть у разных людей — у нас так и есть. Тогда владельцу домена нужно сделать ровно одно: направить домен на IP сервера. Больше от него ничего не требуется.
Сначала — чего делать не надо:
В разделе DNS (у регистраторов он называется «DNS-записи», «Управление зоной», «DNS-серверы и управление зоной»):
| Тип | Имя (хост) | Значение | TTL |
|---|---|---|---|
A |
@ или пусто — это сам my-olymp.ru |
IP сервера | 300 |
A |
www |
тот же IP | 300 |
@ — обязательна, www — по желанию; если её не делать, удалите блок
www из deploy/nginx/my-olymp.ru.conf
и не указывайте -d www.my-olymp.ru при выпуске сертификата.
TTL на время переезда лучше поставить минимальный: если IP придётся поменять, новый разъедется за минуты, а не за сутки.
Удалить всё остальное, что регистратор завёл сам:
A на IP регистратора;AAAA-записи, если они ведут не на наш сервер. Браузер предпочитает
IPv6, а nginx у нас слушает только IPv4 — с чужой AAAA сайт будет
открываться через раз;CNAME на самом домене: на @ его быть не должно, он конфликтует с A.Записи правятся в панели, которая не обслуживает домен. Если NS-серверы домена указывают на одного провайдера (скажем, на Cloudflare или на старый хостинг), а зону вы правите у регистратора, изменения ни на что не влияют.
Проверить, кто отвечает за домен:
dig +short NS my-olymp.ru
Что вернулось — та панель и нужна.
Оставьте режим DNS only (серая тучка), а не «Proxied». Проксирование подменяет TLS на свой и добавляет ещё один слой, который придётся отлаживать вместе с вебхуком MAX; выигрыша для нас в нём нет.
Домен разъезжается от нескольких минут до нескольких часов. Готово, когда обе команды показывают IP сервера:
dig +short my-olymp.ru
dig +short www.my-olymp.ru
Пока dig пуст или показывает чужой адрес — к шагу 2 переходить нельзя:
Let’s Encrypt не подтвердит владение доменом, а у него лимит 5 неудачных
проверок в час. Проще подождать, чем упереться в лимит.
Когда dig показывает нужный IP, владелец домена свободен: всё остальное
делается на сервере.
git clone https://github.com/A-blessed/MAX-olymp.git
cd MAX-olymp
cp .env.example .env
В .env заполнить:
| Переменная | Значение |
|---|---|
BOT_TOKEN |
токен бота |
BOT_USERNAME |
ник бота без @ |
WEBHOOK_SECRET |
произвольная строка 5–256 символов [A-Za-z0-9_-] |
POSTGRES_PASSWORD |
обязателен, только буквы и цифры. Без него compose не стартует. Задаётся до первого up: база запоминает пароль при создании |
CERTBOT_EMAIL |
почта для писем об истечении сертификата |
PUBLIC_BASE_URL=https://my-olymp.ru и CORS_ORIGINS=https://my-olymp.ru
уже стоят в шаблоне — менять их не нужно.
APP_ENV из .env в проде не читается: docker-compose.prod.yml жёстко
ставит production, иначе схема API открылась бы наружу молча.
Сертификаты Минцифры положить в certs/ — без них бэкенд не установит
TLS-соединение с platform-api2.max.ru. Это отдельная история от
сертификата домена, они не взаимозаменяемы:
| Сертификат | Зачем | Откуда |
|---|---|---|
Минцифры, certs/ |
бэкенд → platform-api2.max.ru |
Госуслуги |
| Let’s Encrypt | браузер и MAX → my-olymp.ru |
certbot, шаг 2 |
Курица и яйцо: nginx не поднимется без файла сертификата, а certbot не
сможет подтвердить домен через nginx, пока тот не поднялся. Поэтому
первый выпуск делается в режиме --standalone, когда порт 80 ещё
свободен, — то есть до первого up. .env к этому моменту уже должен
быть заполнен: compose читает его целиком даже ради одного certbot.
docker compose -f docker-compose.prod.yml run --rm -p 80:80 \
--entrypoint certbot certbot \
certonly --standalone --non-interactive --agree-tos \
--email ВАША@ПОЧТА --no-eff-email \
-d my-olymp.ru -d www.my-olymp.ru
Если A-записи для www нет — уберите -d www.my-olymp.ru, иначе выпуск
упадёт на проверке домена. В этом случае удалите и серверный блок www
из deploy/nginx/my-olymp.ru.conf:
без сертификата на www он отдаёт браузеру ошибку.
Сертификат ложится в том letsencrypt — не в репозиторий: закрытый ключ
не должен попасть в git даже случайно.
Продление автоматическое. Сервис certbot дважды в сутки вызывает
renew (до истечения срока больше 30 дней он ничего не делает), а nginx
раз в сутки перечитывает конфигурацию и подхватывает новый файл без
перезапуска и без обрыва соединений.
docker compose -f docker-compose.prod.yml up -d --build
Поднимутся четыре сервиса: db, backend, nginx, certbot. Образы
backend и nginx собираются из исходников тут же, меньше чем за
минуту. Схему базы Alembic применяет сам при старте бэкенда, а пустой
каталог наполняется выгрузками из backend/data/ — руками ничего
импортировать не нужно. Что импортировалось, видно в логе:
docker compose -f docker-compose.prod.yml logs backend | grep data/
Проверить снаружи:
curl https://my-olymp.ru/health
Ожидается {"status":"ok","env":"production","bot_configured":true,"webhook_secret_set":true}.
Оба флага должны быть true — если нет, .env не доехал до контейнера.
И что отдаётся само приложение:
curl -I https://my-olymp.ru/
Адрес вебхука сменился с туннельного на постоянный, поэтому подписку нужно оформить заново — старая продолжает указывать на ngrok:
docker compose -f docker-compose.prod.yml run --rm backend python -m scripts.setup_webhook
Проверить, что осталась только одна подписка и она на новый адрес:
docker compose -f docker-compose.prod.yml run --rm backend python -m scripts.setup_webhook --list
Если в списке остался туннельный адрес, удалить его:
docker compose -f docker-compose.prod.yml run --rm backend \
python -m scripts.setup_webhook --delete --url https://СТАРЫЙ-АДРЕС/webhook
Это важнее, чем кажется: пока подписка висит на мёртвом адресе, MAX считает события недоставленными, а через 8 часов неудач отписывает бота сам.
Через код этого сделать нельзя — в Bot API нет такого метода. Только в кабинете партнёров: Чат-боты → нужный бот → ⋮ → Настройки → поле URL мини-приложения:
https://my-olymp.ru
Пока там стоит адрес GitHub Pages, MAX будет открывать старую копию приложения, и никакие изменения на сервере на это не повлияют. Если доступа к кабинету нет — адрес нужно передать организаторам.
Все образы проекта — Linux-контейнеры, поэтому Docker на Windows должен запускать именно их. Первым делом после установки:
docker compose version
docker info --format ''
Нужны v2.24.0 или новее и linux. Если ответ windows — это Docker
для Windows-контейнеров (его ставит, например, скрипт Microsoft для
Windows Server), и сборка упадёт на no matching manifest for
windows/amd64. В Docker Desktop это переключается пунктом «Switch to
Linux containers» в меню значка.
Если вместо ответа failed to connect to the docker API at
npipe:////./pipe/docker_engine — команда docker есть, а самого движка
нет или он не запущен. docker compose version при этом отвечает
нормально: версия — свойство программы, движок ей не нужен. Что стоит на
самом деле:
Get-Service *docker* | Format-Table Name, Status, DisplayName
docker context ls
Test-Path "C:\Program Files\Docker\Docker\Docker Desktop.exe"
wsl --status
Docker Desktop.exe — запустите его из меню «Пуск» и дождитесь
«Engine running» у значка. Если он падает с ошибкой про WSL или
виртуализацию, на этой машине её нет: нужна вложенная виртуализация от
хостера.docker — это движок Windows-контейнеров. Он
запустится (Start-Service docker), но покажет windows, и проекту
не подойдёт.Что ещё отличается от Linux:
\ в конце в PowerShell пишутся одной строкой,
а вместо curl — curl.exe: просто curl в Windows PowerShell 5.1 —
другая команда..env не создавайте через > или Out-File: они пишут UTF-16, и
compose его не прочитает. Copy-Item .env.example .env, затем
notepad .env.Если Linux-контейнеры поднять так и не удалось — проще взять Linux-VPS; ручной вариант без Docker описан в deploy-without-docker.md.
Если на машине уже работал вариант с Caddy и uvicorn:
Освободить 80 и 443: остановить Caddy — и службу, и задание планировщика, если его так запускали, — и процесс бэкенда. Проверить, что порты свободны:
Get-NetTCPConnection -LocalPort 80,443 -State Listen
Ответ должен быть пустым. Родную PostgreSQL останавливать не обязательно: в Docker у бэкенда своя база, и её порт на хост не публикуется.
.env — в корень репозитория, рядом с docker-compose.prod.yml, а
не в backend\ и не в C:\Server. Взять .env.example и перенести
из старого файла BOT_TOKEN, BOT_USERNAME и WEBHOOK_SECRET —
тот же самый секрет: подписка в MAX оформлена с ним, и с новым
каждое событие получит 403, пока подписку не оформить заново
(шаг 4). DATABASE_URL и EXTRA_CA_CERTS_DIR переносить не нужно:
compose задаёт их сам.certs\ в корне репозитория.--list: адрес вебхука не изменился.Данные пользователей — «Мои олимпиады», прогресс, планы — сами не
переедут: у контейнера своя база. Каталог наполнится заново из
backend/data/, а сохранённое пользователями, если оно нужно,
переносится дампом старой базы до первого запуска бэкенда.
cd MAX-olymp
git pull
docker compose -f docker-compose.prod.yml up -d --build
Пересоберутся и перезапустятся только те сервисы, у которых что-то
поменялось: backend — при правке кода бэкенда, nginx — при правке
frontend/ (статика запечена в его образ). Миграции схемы бэкенд
применяет сам при старте. Статика отдаётся с Cache-Control: no-cache,
иначе встроенный браузер MAX держал бы старую версию и после деплоя.
Данные каталога при обновлении не трогаются: автоимпорт наполняет
только пустую базу. Если в backend/data/ приехали новые выгрузки,
загрузите их явно — импорт обновляет записи на месте и прогресс
пользователей не теряет:
docker compose -f docker-compose.prod.yml exec backend python -m scripts.import_subjects
docker compose -f docker-compose.prod.yml exec backend python -m scripts.import_schedule
Проверить после обновления, не дожидаясь утра, кому и что уйдёт в утренней сводке:
docker compose -f docker-compose.prod.yml exec backend python -m scripts.send_reminders --users
docker compose -f docker-compose.prod.yml exec backend python -m scripts.send_reminders --dry-run
При старте бэкенд сам спрашивает у MAX, оформлена ли подписка на события,
и пишет об этом строку в лог (docker compose -f docker-compose.prod.yml
logs backend | grep Подписка). «MAX не присылает события на …» значит,
что бот глух: не узнает о запуске, не ответит на сообщения, сводки
уходить будет некому. Лечится шагом 4.
Половина поломок деплоя видна не в той строке, на которую смотришь, а в соседней: в дате сертификата, в чужом IP у домена, в том, что контейнер не поднялся вовсе. Пересказывать такое по кускам — долго и ненадёжно, поэтому состояние снимается целиком одной командой, на самой машине:
bash deploy/diagnose.sh
На Windows, где Docker не ставится, — та же команда в PowerShell:
powershell -ExecutionPolicy Bypass -File deploy\diagnose.ps1
-ExecutionPolicy Bypass нужен почти всегда: на Windows Server запуск
скриптов закрыт по умолчанию, и без ключа команда молча откажет.
Политику он меняет только для этого запуска.
Скрипт ничего не меняет и не перезапускает — только читает. Он собирает
в один файл diagnostics-<дата>.txt: версию кода, заданные переменные
окружения, состояние сервисов и их логи, срок и домены сертификата, кто
слушает 80 и 443, куда ведёт DNS и что отвечает сайт — снаружи и в обход
DNS.
Версия для Windows проверяет вдобавок то, что бывает только там:
кодировку .env (UTF-16 от Out-File — та самая причина
UnicodeDecodeError на старте), службу PostgreSQL, занят ли порт 80
службой IIS и есть ли в брандмауэре разрешающие правила на 80 и 443.
Файл сделан пригодным для пересылки: BOT_TOKEN, WEBHOOK_SECRET,
POSTGRES_PASSWORD и CERTBOT_EMAIL в нём заменены на <скрыто> — и в
самом .env, и в логах, куда могли просочиться. Его можно показывать
целиком, не вычитывая.
Таблица ниже — что искать в этом файле.
| Симптом | Где смотреть |
|---|---|
nginx: host not found in upstream "backend" |
бэкенд не стал healthy; docker compose -f docker-compose.prod.yml logs backend |
502 на /api/* |
бэкенд упал или ещё применяет миграции; те же логи |
404 на /docs |
так и задумано: APP_ENV=production закрывает схему |
| Приложение открылось, но данные не грузятся | адрес в кабинете MAX ведёт не на my-olymp.ru — например, на старую копию на GitHub Pages: она ходит в API с чужого origin, и без него в CORS_ORIGINS запросы отклоняются; шаг 5 |
| Бот не отвечает | подписка указывает на старый адрес или её нет вовсе; строка о подписке в логе старта, шаг 4 |
Ошибка сертификата на www |
сертификат выписан без www; либо добавьте его в certbot, либо удалите блок www из конфига nginx |
Address family not supported by protocol в логах nginx |
на хосте отключён IPv6, а в конфиге появились строки listen [::] |
Certbot: DNS problem или Invalid response |
домен ещё не указывает на сервер или порт 80 закрыт; шаг 0 |
| Домен открывает страницу регистратора | не снята парковка или включено перенаправление домена; шаг 0 |
| Сайт открывается через раз | лишняя AAAA-запись: браузер идёт по IPv6, а nginx слушает IPv4; шаг 0 |
Календарь пуст, в ответе /api/me/calendar нет поля olympiads |
после git pull работает старый бэкенд: docker compose -f docker-compose.prod.yml up -d --build backend |
Календарь пуст, olympiads есть, но пустые при сохранённых олимпиадах |
в базу не загружено расписание: docker compose -f docker-compose.prod.yml exec backend python -m scripts.import_schedule. Если уже загружено — у выбранных олимпиад на olimpiada.ru нет дат (так у 85 из 146) |
| Бот не прислал утреннюю сводку | docker compose -f docker-compose.prod.yml exec backend python -m scripts.send_reminders --user <id> скажет причину: от человека не было событий бота, уведомления выключены, нет сохранённых олимпиад или сказать нечего |
В --users у всех «нет событий» |
MAX не присылает события серверу: нет подписки на https://my-olymp.ru/webhook. Шаг 4, затем каждому написать боту любое сообщение |
| «В каталоге … нет сертификатов», хотя они там | смотрите путь в самом сообщении: он абсолютный и говорит, куда приложение смотрело на самом деле |