Развёртывание
August 27, 2026 · View on GitHub
Часть документации Runit. Оглавление — в README.
Развёртывание
В контейнерах (рекомендуется)
Compose поднимает прод-сборку, а она требует боевых секретов, поэтому сначала нужен .env с двумя ключами:
cp .env.example .env
node -e "const c=require('crypto');console.log('JWT_ACCESS_SECRET='+c.randomBytes(32).toString('base64'));console.log('JWT_REFRESH_SECRET='+c.randomBytes(32).toString('base64'))" >> .env
docker compose up --build
Приложение поднимется на http://localhost:8080 (порт меняется переменной WEB_PORT).
Без ключей compose остановится с сообщением, какую переменную задать — так понятнее, чем контейнер, падающий на старте. Обратите внимание: локально compose отдаёт сайт по http, поэтому там выставлен COOKIE_SECURE=false — браузер не отправляет Secure-cookie по http, и вход бы не работал. На боевом стенде за TLS переменную задавать не нужно.
Состав:
- app — бэкенд (Fastify + tRPC), слушает
3001внутри сети compose, БД в томеrunit-data; - web — Caddy: отдаёт собранную статику фронтенда и проксирует
/trpcи/healthна бэкенд (SPA-роуты вида/editor/123отдаютindex.html).
Миграции прогоняются автоматически при старте бэкенда.
Проверка живости
GET /health — 200, если приложение отвечает и видит БД; 503, если база недоступна. Используется в HEALTHCHECK образа и в depends_on: service_healthy у compose.
Серверный раннер в контейнере
По умолчанию docker-сокет в контейнер не монтируется: доступ к сокету равносилен root на хосте, а раннер исполняет чужой код. В этом режиме приложение работает полностью, а серверный запуск кода отвечает понятной подсказкой.
Для боевого запуска с раннером используйте выделенный runner-хост либо rootless/удалённый демон (RUNNER_DOCKER_BIN, DOCKER_HOST). Общая файловая система приложению и демону не нужна: код сниппета едет в контейнер первой строкой stdin, а не монтированием каталога — поэтому удалённый демон работает так же, как локальный. Локально для проверки можно раскомментировать монтирование сокета в docker-compose.yml — но не в проде.
Как давать доступ к демону в проде
Три рабочих варианта, от простого к надёжному:
| Схема | Что делать | Чем плоха |
|---|---|---|
| Rootless docker (рекомендуем для старта) | Демон под непривилегированным пользователем, приложение и раннер на одной машине | Побег из контейнера даёт права этого пользователя, а не root; чуть медленнее сеть и оверлей |
| Выделенный runner-хост | Отдельная машина только под запуск кода, приложение ходит на неё по DOCKER_HOST | Дороже; канал к демону обязан быть закрыт mTLS, иначе доступ к демону получает вся сеть |
| Демон по mTLS | DOCKER_HOST=tcp://…, DOCKER_TLS_VERIFY=1, DOCKER_CERT_PATH | Возня с сертификатами и их ротацией |
Чего делать нельзя ни в каком варианте: монтировать /var/run/docker.sock в контейнер приложения. Это выдаёт root на хосте любому, кто найдёт дыру в приложении, — и обесценивает всю изоляцию песочницы.
Профиль seccomp
Переменная RUNNER_SECCOMP_PROFILE задаёт путь к файлу профиля. Если она не задана, работает штатный профиль docker — это аллоулист примерно на 300 сисколлов, который уже блокирует mount, ptrace, bpf, keyctl и остальное опасное.
Важно: --security-opt seccomp=<файл> профиль заменяет, а не дополняет. Поэтому файл вида «запретить пару вызовов, остальное разрешить» будет ослаблением, а не усилением: он снимет защиту со всего, что не перечислено. Свой профиль имеет смысл только как полный аллоулист, выверенный на всех девяти языках, — включая JIT у JVM и сборку у go/g++.
На боевом стенде
Образы собирает и публикует CI в GHCR (publish.yml): main → тег edge, тег vX.Y.Z → 1.2.3, 1.2 и latest. На сервере они только скачиваются — собирать на месте нельзя: раскатывался бы не тот образ, что прошёл проверки.
RUNIT_VERSION=1.2.3 docker compose -f docker-compose.prod.yml up -d --wait
Отличия от локального compose:
- PostgreSQL — managed у провайдера, а не контейнером рядом. Резервные копии, обновления версий и восстановление на момент времени контейнер с томом не решает, а для персональных данных это обязательно. База должна находиться на территории РФ (152-ФЗ, ст. 18 п. 5 — #868);
- секреты и
DATABASE_URLлежат в.envрядом с compose-файлом на сервере; в репозиторий и в образ они не попадают; COOKIE_SECUREне задаётся — в production он включён по умолчанию, и стенд обязан работать за TLS;- раннер по умолчанию выключен: см. выше про доступ к docker-сокету.
Миграции приложение прогоняет само при старте, отдельного шага не нужно.
Включить серверное исполнение на стенде
Пока раннер выключен, из двенадцати языков работают три: JavaScript (Web Worker в браузере) и HTML/CSS (превью). Остальные девять отвечают «Серверное исполнение сейчас недоступно» — это честное сообщение, но половина смысла сервиса.
Чтобы включить, нужен доступ к демону, который не является докером приложения (см. таблицу схем выше). Дальше — две переменные в .env рядом с compose-файлом:
RUNNER_ENABLED=true
DOCKER_HOST=tcp://runner-host:2376 # плюс DOCKER_TLS_VERIFY=1 и DOCKER_CERT_PATH
Образы подтянутся сами при старте приложения: RUNNER_IMAGE_PREFIX по умолчанию указывает на GHCR, а RUNNER_IMAGE_TAG равен RUNIT_VERSION — то есть версии образов раннера всегда совпадают с версией приложения. Проверить, что всё на месте, можно по логу приложения (строки [runner] образы …) или смоуком:
npm run runner:smoke https://runit.example.org
На PaaS (Heroku и совместимые)
Одно приложение отдаёт и интерфейс, и API. Разнести их по двум приложениям нельзя, и это не вопрос удобства: клиент tRPC зовёт бэкенд относительным путём /trpc (frontend/src/application.tsx), а cookie сессии выставлены с sameSite: 'lax' (src/auth/cookies.ts) — в кросс-сайтовом fetch браузер их не отправит, то есть вход не работал бы вовсе. Значит origin должен быть один.
Отдаёт интерфейс сам Fastify (src/staticSite.ts) — на PaaS второго процесса перед приложением нет, и раздавать статику больше некому. Модуль включается, только если frontend/dist существует: в образе бэкенда его нет, поэтому docker-схема работает как раньше, а Caddy остаётся основным способом.
Правила раздачи перенесены из frontend/Caddyfile.docker, а не выдуманы заново — SPA-заглушка на неизвестный путь, метатеги ботам на /s/:code, разрешённый фрейминг для /embed/*. Расхождение двух схем развёртывания опаснее самих правил: ошибку, которая видна только на одном стенде, ищут в последнюю очередь.
Сборку запускает heroku-postbuild: он собирает бэкенд, затем фронтенд и удаляет frontend/node_modules, чтобы слаг не раздувался. Флаг --include=dev там обязателен — PaaS задаёт NODE_ENV=production, и без него npm ci не поставил бы Vite, то есть сборка фронтенда упала бы.
Отдельной фазы release для миграций нет намеренно: она вызывала drizzle-kit, а он лежит в devDependencies, которые PaaS вырезает в проде, — команда падала и отменяла релиз. Миграции приложение прогоняет само при старте.
Что задать в настройках приложения:
heroku addons:create heroku-postgresql:essential-0 # даст DATABASE_URL
heroku config:set \
JWT_ACCESS_SECRET="$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")" \
JWT_REFRESH_SECRET="$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")" \
CORS_ORIGIN="https://<приложение>.herokuapp.com" \
TRUST_PROXY_HOPS=1 \
DATABASE_POOL_MAX=5
TRUST_PROXY_HOPS=1 — перед приложением ровно один прокси, маршрутизатор платформы. С нулём все посетители попадают в одну корзину лимитера, и один скрипт выключает вход для всех. DATABASE_POOL_MAX=5 — у младшего тарифа PostgreSQL общий предел соединений на все инстансы (у essential-0 это 20).
Перевод боевой базы со схемы TypeORM
Делается один раз, до первого запуска новой версии на стенде, который жил на старом NestJS-стеке (runit.hexlet.ru, приложение hexlet-editor).
Без этого шага деплой кладёт сайт. Приложение прогоняет миграции при старте, а первая из них начинается с CREATE TABLE "users" — в боевой базе эта таблица существует с 2019 года. Миграция падает, ошибка перебрасывается наружу, процесс не поднимается; фазы release, которая отменила бы выпуск, в Procfile нет намеренно, поэтому приложение уйдёт в crashed и откатывать придётся руками.
Скрипт — bin/typeorm-to-drizzle.sql. Он приводит старую схему к целевой и штампует журнал Drizzle, чтобы первые три миграции считались применёнными (мигратор сверяется только по created_at из drizzle/meta/_journal.json, хеш он не перепроверяет). Всё в одной транзакции: при любой непройденной проверке база остаётся в исходном состоянии.
heroku pg:backups:capture -a hexlet-editor
URL=$(heroku config:get DATABASE_URL -a hexlet-editor)
psql "$URL" -v ON_ERROR_STOP=1 -v dry_run=1 -f bin/typeorm-to-drizzle.sql # проверка
psql "$URL" -v ON_ERROR_STOP=1 -f bin/typeorm-to-drizzle.sql # перевод
Сухой прогон делает всю работу и откатывает — он отвечает на вопрос «готовы ли данные», не меняя их. Проверки докладывают все найденные проблемы сразу (дубли (user_id, slug), дубли настроек у одного пользователя, значения, не влезающие в суженные типы), чтобы разбор не превращался в череду прогонов по боевой базе.
Старые сниппеты получают visibility = 'link'. Это перенос прежнего поведения: понятия видимости в старой версии не было, а GET /snippets/:username/:slug шёл без гварда — по ссылке сниппет открывался любому. 'private' сломало бы все разосланные ссылки, а 'public' вывело бы код в новый публичный профиль /u/:username, которого раньше не существовало.
Проверялось не на проде: старая схема воссоздаётся по архивным миграциям (legacy-nestjs-archive), скрипт применяется к копии, и результат сравнивается с эталоном — схемой, которую drizzle-kit migrate строит на чистой базе. Схемы совпадают, контрольные суммы данных до и после совпадают, последовательности продолжают нумерацию.
Выпуск
Раскатку на PaaS делает job deploy-heroku в .github/workflows/deploy.yml — см. общий раздел «Выпуск версии». Чтобы он заработал, в настройках репозитория нужны переменная HEROKU_APP и секрет HEROKU_API_KEY (heroku authorizations:create, а не heroku auth:token — второй короткоживущий и деплой начнёт падать с Invalid credentials). Пока их нет, job называет причину пропуска и завершается успешно.
Docker на PaaS обычно недоступен, поэтому серверное исполнение кода там деградирует: работают JavaScript (Web Worker в браузере) и HTML/CSS (превью), остальные девять языков честно отвечают «Серверное исполнение сейчас недоступно» (см. выше).
Переменные окружения
Полный список с примерами — в .env.example; файл читается через dotenv, .env в .gitignore.
| Переменная | По умолчанию | Назначение |
|---|---|---|
PORT | 3001 | Порт бэкенда |
HOST | 0.0.0.0 | Интерфейс бэкенда |
DATABASE_URL | postgres://runit:runit@localhost:5432/runit | Строка подключения, обязательна в production |
DATABASE_POOL_MAX | 10 | Предел соединений на инстанс |
DATABASE_SSL | require в проде, иначе prefer | Режим TLS до базы: require, prefer, verify-full, off. Managed-PostgreSQL открытое соединение отвергает, а драйвер по умолчанию идёт без TLS |
WEB_PORT | 8080 | Внешний порт compose |
NODE_ENV | development | production требует боевых секретов и включает HSTS |
LOG_LEVEL | по NODE_ENV | Уровень pino: прод — info, разработка — debug, тесты — silent |
JWT_ACCESS_SECRET | дев-значение вне прода | Ключ подписи access-токена, обязателен в production |
JWT_REFRESH_SECRET | дев-значение вне прода | Ключ подписи refresh-токена, обязателен в production |
COOKIE_SECURE | true в проде | Флаг Secure у cookie сессии; выключать только там, где нет TLS |
CORS_ORIGIN | http://localhost:3000 | Origin(ы) фронтенда, которым разрешены запросы с cookie |
RATE_LIMIT_AUTH | 10 | Запросов в минуту на auth.* (антибрутфорс) |
Приложение проверяет окружение на старте и отказывается запускаться в production, если секреты не заданы или равны дев-значениям из .env.example. Это сделано намеренно: молча подписать токены предсказуемым ключом хуже, чем не подняться (#864). Локально, в тестах и в CI секреты задавать не нужно — там подставляются дев-значения.
Переменные раннера (RUNNER_*) описаны выше, в разделе про серверное исполнение кода.
Выпуск версии
Мержим squash: один PR — один коммит в main — одна запись в CHANGELOG. Мерж-коммит и rebase унесли бы в main внутренние коммиты ветки, и release-please прочитал бы каждый — в CHANGELOG попадали бы wip и поправил опечатку.
Теги руками не ставят. Версию считает release-please по conventional commits — так же, как в hexlet-basics и hexlet/hexlet, и с теми же настройками (release-please-config.json), чтобы история версий читалась одинаково во всех репозиториях.
Как выпустить
- Мержите обычные PR в
main. Сообщения коммитов — conventional:feat: …,fix: …,perf: …,chore: …,build: …. От типа зависит и раздел в CHANGELOG, и величина бампа. - На каждый пуш в
mainrelease-please обновляет релизный PR с заголовком видаchore(main): release 0.3.0. В нём — поднятая версия вpackage.jsonи дописанныйCHANGELOG.md. Пока он открыт, ничего не выпущено. - Мерж релизного PR — и есть выпуск. Он создаёт тег
v0.3.0и запись в GitHub Releases, а тот же прогон публикует образы и раскатывает стенды.
Всё. Никаких git tag, make release и git push heroku.
Пока версия ниже 1.0.0, feat поднимает patch, а не minor (bump-patch-for-minor-pre-major): до первого мажора минор экономят на что-то более значимое, чем очередная фича.
Что происходит после мержа релизного PR
| Что | Где |
|---|---|
Тег vX.Y.Z и запись в GitHub Releases | release-please.yml, job release-please |
| Образы приложения, фронтенда и девяти раннеров в GHCR | вызов publish.yml |
| Раскатка на свой сервер и на PaaS | вызов deploy.yml |
Триггеров по тегу (push: tags: v*) в этих workflow нет намеренно. Тег ставит release-please встроенным GITHUB_TOKEN, а созданные им события другие workflow не запускают — GitHub так защищается от бесконечных цепочек. Пока публикация и раскатка висели на теге, выпуск закончился бы записью в Releases без образов и без деплоя. Поэтому оба workflow сделаны переиспользуемыми (workflow_call) и вызываются из того же прогона. Альтернатива — токен GitHub App, как в hexlet/hexlet; она требует установки приложения в организацию, а этот способ не требует ни одного нового секрета.
Следить за выпуском
gh run watch "$(gh run list --workflow release-please.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
gh release view # что попало в CHANGELOG
Если раскатка упала
Записи в Releases и тег уже созданы — переделывать выпуск не нужно, достаточно перезапустить упавший job: gh run rerun <id> --failed. Что делать с самим стендом, если он не поднялся, — см. соответствующий раздел развёртывания.
Ручной прогон публикации без выпуска (например, пересобрать образы раннеров): gh workflow run publish.yml. Без входа version уезжает только тег edge.