Развёртывание

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, иначе доступ к демону получает вся сеть
Демон по mTLSDOCKER_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.Z1.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.

ПеременнаяПо умолчаниюНазначение
PORT3001Порт бэкенда
HOST0.0.0.0Интерфейс бэкенда
DATABASE_URLpostgres://runit:runit@localhost:5432/runitСтрока подключения, обязательна в production
DATABASE_POOL_MAX10Предел соединений на инстанс
DATABASE_SSLrequire в проде, иначе preferРежим TLS до базы: require, prefer, verify-full, off. Managed-PostgreSQL открытое соединение отвергает, а драйвер по умолчанию идёт без TLS
WEB_PORT8080Внешний порт compose
NODE_ENVdevelopmentproduction требует боевых секретов и включает HSTS
LOG_LEVELпо NODE_ENVУровень pino: прод — info, разработка — debug, тесты — silent
JWT_ACCESS_SECRETдев-значение вне продаКлюч подписи access-токена, обязателен в production
JWT_REFRESH_SECRETдев-значение вне продаКлюч подписи refresh-токена, обязателен в production
COOKIE_SECUREtrue в продеФлаг Secure у cookie сессии; выключать только там, где нет TLS
CORS_ORIGINhttp://localhost:3000Origin(ы) фронтенда, которым разрешены запросы с cookie
RATE_LIMIT_AUTH10Запросов в минуту на 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), чтобы история версий читалась одинаково во всех репозиториях.

Как выпустить

  1. Мержите обычные PR в main. Сообщения коммитов — conventional: feat: …, fix: …, perf: …, chore: …, build: …. От типа зависит и раздел в CHANGELOG, и величина бампа.
  2. На каждый пуш в main release-please обновляет релизный PR с заголовком вида chore(main): release 0.3.0. В нём — поднятая версия в package.json и дописанный CHANGELOG.md. Пока он открыт, ничего не выпущено.
  3. Мерж релизного 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 Releasesrelease-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.