HERON

Спецификация 22

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

Эта спецификация описывает, как сайт, собранный HERON, попадает на сервер и живёт там: контейнеры, скрипты обновления, CI, минимум для nginx, переезд со старого сайта и бэкап.

Она для того, кто ведёт сервер или репозиторий сайта. Специфика конкретного проекта живёт в документах самого проекта, а не здесь.

Простыми словами

Собранный сайт — это набор готовых страниц. Чтобы его видели все, он должен жить на сервере: компьютере, который всегда включён и отвечает посетителям. Эта страница описывает, как там живёт сайт на HERON.

Сайт переезжает как образ: готовые страницы, упакованные вместе с небольшим веб-сервером, как запечатанная коробка. Сервер запускает коробку; новая версия сайта — новая коробка, которая встаёт на место старой. Если что-то пошло не так, старая коробка никуда не делась и возвращается.

Пример. Запустить образ сайта на сервере и потом обновить его:

docker run -d --name my-site -p 80:8080 --restart unless-stopped ghcr.io/you/my-site:prod
docker pull ghcr.io/you/my-site:prod && docker rm -f my-site && docker run -d --name my-site -p 80:8080 --restart unless-stopped ghcr.io/you/my-site:prod

Первая команда запускает сайт. Вторая скачивает новую версию и меняет коробку. С git и GitHub Actions новый образ собирается сам при каждом push; подробнее — публикация через git.

Когда нужна эта страница. Когда вы настраиваете сервер, автоматизируете обновления, переносите старый сайт или продумываете резервные копии.

Статус на сегодня

Статус на 2026-10-04. Работающий сегодня путь выкладки — образ сайта: scripts/site.sh <сайт> image|smoke|push <окружение> в репозитории HERON (поставка, выходы), дальше образ запускается как любой контейнер nginx. Схема «сборка на сервере» из разделов контейнеры и скрипты (compose со сборщиком, install.sh, deploy.sh, deploy.conf) — целевая: этих скриптов в репозитории HERON пока нет. Разделы CI, nginx и переезд действуют для обоих путей.

На практике пошаговый гайд — сборка и выкладка, а способы хранения и публикации разобраны на странице публикация.

Что разворачиваем

Один сайт — одна папка и один репозиторий. В нём контент, данные, картинки, тема и конфиг. Кода HERON в нём нет: HERON приходит образом ghcr.io/<владелец>/heron:<версия>, версия объявлена в site.yaml.

Отсюда два следствия, определяющих весь деплой:

  • обновление HERON — это смена одной строки в site.yaml и пересборка, а не мерж чужого кода в репозиторий сайта;
  • откат HERON — возврат этой строки.

Контейнеры

Два, оба лёгкие. Третий (relay) появляется только на сайтах с формами.

Сборщик — образ HERON. Запускается разово, собирает сайт в dist/ и завершается. Между сборками не работает и ресурсов не ест. Собирать его на целевой машине не нужно: он тянется из реестра.

Веб — nginx, отдаёт dist/. Единственный постоянно работающий процесс. Образ nginxinc/nginx-unprivileged — мастер-процесс не от рута, слушает 8080; наружу смотрит прокси.

Базы данных нет. Приложения нет. Отдавать статику — единственное, что делает сайт в проде.

services:
  build:
    image: ghcr.io/OWNER/heron:0.1.0
    profiles: ["build"]              # не поднимается вместе с web
    user: "${UID}:${GID}"
    network_mode: "none"             # сборке сеть не нужна
    read_only: true
    tmpfs: ["/tmp"]
    cap_drop: ["ALL"]
    security_opt: ["no-new-privileges:true"]
    volumes:
      - .:/site
    command: ["build", "--strict"]

  web:
    image: nginxinc/nginx-unprivileged:1.27-alpine
    restart: unless-stopped
    volumes:
      - ./dist:/usr/share/nginx/html:ro
      # конфиг собирает HERON: языки, 404, карты 301 и 410 уже внутри
      - ./dist/.heron-nginx.conf:/etc/nginx/conf.d/default.conf:ro
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
      interval: 30s
      timeout: 3s
      retries: 3

dist/ монтируется в nginx только на чтение. Сборщик пишет туда, веб только читает.

Сборщику не монтируется .env: секретов у сборки нет, она идёт без сети. Разбор модели безопасности — поставка, безопасность.

Два скрипта

Оба лежат в директории проекта на сервере, оттуда и запускаются. Стиль — set -e, понятные шаги, внятное падение.

install.sh — один раз при первой установке

  1. Проверяет наличие docker и docker compose, версии.
  2. Создаёт dist/, cache/, logs/ с нужными правами.
  3. Копирует .env.example в .env, если его нет, и останавливается с просьбой заполнить. Не запускается с пустыми секретами (актуально для сайтов с relay; на статическом сайте файл остаётся пустым).
  4. Проверяет, что deploy.conf заполнен.
  5. Тянет образ HERON нужной версии из реестра.
  6. Первая сборка сайта.
  7. Поднимает nginx.
  8. Печатает, что дальше: проверить сайт, настроить проксирование, выпустить сертификат.

deploy.sh — при каждом обновлении

  1. Читает deploy.conf и .env.
  2. git pull нужной ветки.
  3. Сверяет версию HERON из site.yaml с локально доступным образом; при расхождении тянет нужный тег.
  4. Собирает сайт во временный каталог dist.new/, не трогая работающий.
  5. Прогоняет heron check --strict. Ошибки — остановка, dist/ остаётся прежним.
  6. Атомарная подмена: dist → dist.old, dist.new → dist, перезагрузка nginx.
  7. При падении на любом шаге — возврат dist.old и внятное сообщение.
  8. Чистит dist.old после успешной проверки.

Ключевое: сборка идёт в сторону, подмена атомарна. Битый коммит не оставляет сайт наполовину обновлённым. Откат — это возврат предыдущего каталога, а не пересборка.

Два файла конфигурации

deploy.conf — параметры окружения, лежит в git:

REPO_BRANCH=main
SITE_DIR=/opt/site
DOMAIN=example.com
KEEP_RELEASES=3

.env — секреты, в git отсутствует, права 600. На статическом сайте пуст: секреты появляются с relay. Правила обращения — в контракте, раздел .env.

Разделение принципиальное: deploy.conf можно показать кому угодно, .env нельзя никому.

CI

По событию в репозитории сайта:

  1. heron check --strict — падает на ошибках и на предупреждениях.
  2. heron build во временный каталог.
  3. Проверка карты редиректов: каждый старый URL отдаёт 301 на адрес, отвечающий 200.
  4. Lighthouse по собранному сайту, пороги задаёт сайт.
  5. Только если всё зелёное — deploy.sh.

Тесты самого HERON гоняются в CI репозитория HERON, а не сайта: сайт потребляет готовый образ и не обязан знать, как тот собран.

Кэш нарезок в CI. Без него каждая сборка холодная: на сайте в сотню картинок это минуты процессорного времени на любую правку текста. Кэш лежит в .heron-cache/ рядом с папкой сборки и кладётся в кэш раннера как обычная папка:

- uses: actions/cache@v4
  with:
    path: .heron-cache
    key: heron-media-${{ hashFiles('media/**', 'theme/theme.yaml') }}
    restore-keys: heron-media-

Ключ включает тему не для красоты: widths, ratios и formats входят в хеш каждой записи, и правка theme.yaml обесценивает кэш целиком.

Кэш влияет только на скорость. Потерялся, протух, не восстановился — сборка просто будет холодной, результат тот же побайтово. Поэтому промахи кэша чинить не нужно, а build.workers в CI стоит прибить руками: ядра там делят с другими задачами.

Если сайт включил build.updated_from: git, истории нужно доехать до сборки. GitHub Actions клонирует репозиторий обрезанным — только последний коммит, — и тогда история формально есть, а дат в ней нет: HERON откатится на время файлов, а файлы после клона все датированы временем клона. Весь сайт объявит себя обновлённым сегодня, и так при каждой сборке. Лечится одной строкой:

- uses: actions/checkout@v7
  with:
    fetch-depth: 0

То же самое касается сборки из архива или из копии папки без .git: даты перейдут на время файлов. Если даты важны — история должна быть рядом с контентом в момент сборки.

Пункт 3 — не формальность. Это единственное, что защищает индекс при переезде со старой системы.

nginx

Минимум, который обязан быть в конфиге:

  • отдача статики с длинным кэшем для assets/ и media/, коротким для HTML;
  • gzip и brotli;
  • 301 из redirect_from и 410 из gone отдаются без обращения к файлам. Карты уже вписаны в конфиг, который HERON собирает вместе с сайтом (dist/.heron-nginx.conf); redirects.map и gone.map в dist/ — человекочитаемые копии;
  • try_files с завершающим слешем, единый канонический вид URL;
  • /healthz для healthcheck;
  • заголовки: X-Content-Type-Options, Referrer-Policy, Content-Security-Policy, HSTS;
  • запрет исполнения чего-либо: сайт статический, PHP и любой обработчик отсутствуют как класс;
  • закрыты dot-файлы.

CSP имеет смысл строгий: собственных скриптов единицы килобайт, внешних нет. script-src 'self' без исключений. На сайтах с формой добавляется connect-src до адреса relay — и больше ничего.

Переезд с существующего сайта

Порядок, при котором сайт не проседает. Конкретные правила редиректов — дело сайта, здесь только последовательность.

Подготовка. Новый сайт собирается и поднимается на служебном поддомене. Проверяется всё: страницы, языки, разметка, скорость.

Карта редиректов. Полный список старых адресов снимается с живого sitemap. Для каждого прописывается redirect_from в соответствующем md-файле; то, чего больше не существует, — архивы, служебные пути, дорвеи — пишется в gone в site.yaml, точными адресами или префиксами (site.yaml). Тест из CI, пункт 3, проходит на служебном домене.

Разница между 301 и 410 принципиальна: 301 говорит поисковику «адрес переехал, вот новый», 410 — «этого больше нет, выкинь из индекса». Служебные сущности старой системы — это второе, и притворяться, что они временно недоступны, смысла нет.

Пример. На старом сайте была страница /our-services/roofing.html; на новом это content/ru/services/roof.md. Во фронтматтере новой страницы записан старый адрес, и HERON пишет для него настоящий 301:

redirect_from:
  - /our-services/roofing.html

Переключение. Прокси переводится на новый контейнер. Старый стек остаётся поднятым, но недоступным снаружи — на случай отката.

После. Sitemap переотправляется в Search Console. Неделю смотрим отчёт по ошибкам сканирования. Если тихо — старый стек гасится, каталоги архивируются.

Практический гайд по переезду с примерами — SEO и языки.

Бэкап

Резервировать нужно только то, что нельзя восстановить сборкой.

Нужно: репозиторий с контентом и картинками — он же и есть источник истины. .env. Конфиг nginx.

Не нужно: dist/ — пересобирается за секунды. cache/ — тем более. Образ HERON — он в реестре и прибит версией.

Сайт целиком восстанавливается из git одной командой. Это главное практическое преимущество перед системами, где восстановление означает разворачивание базы.

Что появится позже

Заложено в структуре, не реализуется сейчас:

heron-relay — третий контейнер на сайтах с формами: приём POST, валидация по схеме из site.yaml, антиспам, рассылка в почту, вебхук CRM и Telegram. Без базы; SQLite только как спул повторной отправки. Секреты из смонтированного .env, только в этот контейнер — nginx про них не знает.

Собственная аналитика — приём событий вместо внешнего счётчика. Отдельный эндпоинт relay, отдельное решение по хранению.

Админка — веб-редактор md-файлов с запуском пересборки. Правило, без которого её нельзя делать: админка правит файлы и коммитит их в git, а не пишет в базу. Иначе HERON превращается в ту самую систему, от которой сайты уходят.

Ни одно из трёх не требует переписывания того, что делается сейчас, — при условии, что границы из спецификации сборки и поставки соблюдены. Текущие планы — в плане развития.

Источник

Эта страница повторяет docs/spec/22-deploy.md из ветки prod репозитория HERON (документ 3 из 4; дата 2026-09-08, правка 2026-09-14). Первым обновляется репозиторий, эта страница — сразу следом. Последняя версия всегда здесь: 22-deploy.md на GitHub.