Превью‑стенды экономят часы на ревью фич: каждую ветку Git можно открыть живьём по отдельному поддомену, а не собирать инструкции для локального запуска. Самый быстрый способ — wildcard DNS на дев‑домен и маршрутизация через Nginx map
на одной VDS. Получается гибко, прозрачно и без тяжёлых оркестраторов.
Идея и архитектура
Мы создаём поддомены вида branch.dev.example.com для всех веток Git. Wildcard DNS отправляет весь трафик на VDS с Nginx. Там map
разбирает $host
, определяет имя ветки и направляет запрос в соответствующий backend — процесс приложения, поднятый на уникальном порту. Как только ветка удаляется, процесс останавливается, запись в map
исчезает, и поддомен больше не резолвится на сервис.
Такой подход хорош для preview environment под каждую задачу и для классических dev/stage инсталляций. Он не требует перезапуска Nginx на каждую ветку, если карту генерировать из темплейта и делать аккуратный reload
без простоя.
Схема именования поддоменов
Главное — согласовать правила трансформации имён веток в DNS‑допустимые лейблы. Ограничения: 63 символа на лейбл, латиница, цифры и дефис; подчеркивания и слеши нельзя. Типовые правила:
- Переводим в нижний регистр.
- Заменяем недопустимые символы на дефис (
/
,_
,.
, пробелы, спецсимволы). - Урезаем до вменяемой длины (например, 32–40 символов) и добавляем хэш‑суффикс от исходного имени для уникальности.
Совет: фиксируйте одинаковые правила в CI и в серверных скриптах, чтобы URL и сервисы всегда сходились.
DNS: wildcard записи
На стороне DNS понадобится поддомен для превью — например, dev.example.com — и wildcard‑запись, которая указывает на ваш сервер. Если домена ещё нет, оформите его через регистрацию доменов. Минимальный набор:
dev.example.com
— A/AAAA на IP вашей VDS.*.dev.example.com
— A/AAAA на тот же IP.
Если у вас есть отдельные пространства под stage или pr, добавьте и их wildcard. TTL для дев‑зон можно сделать поменьше (например, 60–300 секунд), чтобы быстрее обновлялись записи при перенастройках.
Nginx: извлекаем ветку из хоста и роутим через map
Базовый трюк: используем переменные и map
, чтобы из $host
выделить ветку, а затем выбрать backend. Начнём с простого варианта — backend в виде host:port
в переменной. Для дев‑нагрузки это ок.
# /etc/nginx/conf.d/preview-map.conf
# 1) Извлекаем лейбл ветки из host
map $host $branch_label {
~^(?<sub>[^.]+)\.dev\.example\.com$ $sub;
default "";
}
# 2) Соответствие лейбла и backend (порт или адрес)
# Этот блок генерируется скриптом при добавлении/удалении стенда
map $branch_label $branch_upstream {
# примеры статических соответствий
main 127.0.0.1:9001;
stage 127.0.0.1:9002;
default 127.0.0.1:9000; # страница-заглушка 404
}
# Для WebSocket/HTTP2 upgrade
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name ~^([^.]+)\.dev\.example\.com$;
# Защита от случайных хостов
if ($branch_label = '') { return 444; }
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 120s;
proxy_connect_timeout 5s;
proxy_pass http://$branch_upstream;
}
}
Вариант с переменной в proxy_pass
прост, но не поддерживает keepalive к upstream на уровне Nginx. Для превью‑нагрузки это не критично. Если хотите выжать максимум, генерируйте имена upstream
‑блоков и маппите на них константами, а в proxy_pass
используйте http://имя_upstream
. Тогда возможен keepalive и общий пул соединений.
Страница‑заглушка и безопасное значение по умолчанию
Значение default
в карте должно вести на явную заглушку, отдающую 404 и короткую подсказку. Так вы избежите случайной утечки трафика на не тот backend.
# /etc/nginx/conf.d/preview-404.conf
server {
listen 127.0.0.1:9000;
server_name _;
location / { return 404; }
}
Автоматизация генерации map
Ручками править карту неудобно. Создадим отдельный файл, который полностью перегенерируется скриптом при добавлении/удалении стенда. Включим его в основной конфиг Nginx:
# /etc/nginx/nginx.conf (фрагмент в http)
include /etc/nginx/maps/branches.map;
Содержимое /etc/nginx/maps/branches.map
— полноценный блок map
, чтобы Nginx мог atomically подхватить изменения:
# сгенерированный файл (не править вручную)
map $branch_label $branch_upstream {
main 127.0.0.1:9001;
stage 127.0.0.1:9002;
default 127.0.0.1:9000;
}
Два скрипта обслуживают жизненный цикл: add‑preview и remove‑preview. Первый собирает приложение ветки, стартует процесс на свободном порту (или стабильном вычисляемом), добавляет соответствие в карту и делает nginx -s reload
. Второй останавливает процесс и пересобирает карту без записи.
Выбираем порт: стабильно и без коллизий
Для предсказуемости удобно привязывать порт к имени ветки. Простейший вариант — базовый порт плюс хэш от лейбла. Например, 5000 + (crc32(label) mod 1000). Можно хранить соответствия в текстовом реестре, чтобы при коллизии выбрать первый свободный.
# пример грубого вычисления порта (bash + sha1sum + awk)
label="$1"
base=5000
span=1000
hash=$(printf "%s" "$label" | sha1sum | awk '{print $1}')
num=$((0x${hash:0:4} % span))
port=$((base + num))
echo "$port"
Для продвинутых сценариев храните JSON/INI с назначенными портами и проверяйте, занят ли порт, перед запуском.
Системный юнит для процессов превью
Удобно шаблонизировать запуск веток через systemd, чтобы единообразно стартовать/стопить. Шаблонный юнит с параметром — именем ветки (уже санитизированной):
# /etc/systemd/system/myapp-preview@.service
[Unit]
Description=MyApp preview for branch %i
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/myapp
Environment=BRANCH=%i
EnvironmentFile=-/opt/myapp/.env.preview
ExecStart=/opt/myapp/scripts/run-preview.sh %i
Restart=on-failure
RestartSec=2s
[Install]
WantedBy=multi-user.target
Скрипт run-preview.sh
принимает лейбл ветки, вычисляет порт, экспортирует PORT
и запускает приложение (например, PHP‑FPM pool, Node, Python, Go). В нём же можно писать health‑лог и PID.
# /opt/myapp/scripts/run-preview.sh
#!/usr/bin/env bash
set -euo pipefail
label="$1"
port=$(/opt/myapp/scripts/port-for.sh "$label")
export PORT="$port"
# Пример для Node.js
exec /usr/bin/node /opt/myapp/build/server.js
Скрипты: добавить и удалить превью‑стенд
Скрипт добавления: клонирует/обновляет код ветки, билдит, стартует systemd‑юнит и перегенерирует карту.
# /opt/myapp/scripts/add-preview.sh
#!/usr/bin/env bash
set -euo pipefail
raw_branch="$1"
# санитаризация в DNS‑лейбл
label=$(echo "$raw_branch" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g' | sed -E 's/^-+|-+$//g' | cut -c1-40)
# сборка
cd /opt/myapp
if [ -d repo ]; then
cd repo && git fetch --all --prune && git checkout "$raw_branch" && git pull --ff-only
else
git clone --origin origin /opt/git/remote.git repo
cd repo && git checkout "$raw_branch"
fi
# ваш билд: npm ci && npm run build, composer install, и т.п.
# запуск превью
systemctl daemon-reload
systemctl enable --now myapp-preview@"$label"
# зарегистрировать backend
port=$(/opt/myapp/scripts/port-for.sh "$label")
/opt/myapp/scripts/rebuild-map.sh "$label" "127.0.0.1:$port"
nginx -t
nginx -s reload
echo "Ready: ${label}.dev.example.com"
Скрипт удаления: останавливает юнит, удаляет соответствие и перезагружает Nginx.
# /opt/myapp/scripts/remove-preview.sh
#!/usr/bin/env bash
set -euo pipefail
raw_branch="$1"
label=$(echo "$raw_branch" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g' | sed -E 's/^-+|-+$//g' | cut -c1-40)
systemctl disable --now myapp-preview@"$label" || true
/opt/myapp/scripts/rebuild-map.sh --remove "$label"
nginx -t
nginx -s reload
Генератор карты читает текущие пары лейбл→backend из простого каталога или KV‑хранилища и выплёвывает полный блок map
:
# /opt/myapp/scripts/rebuild-map.sh (пример с простым хранилищем)
#!/usr/bin/env bash
set -euo pipefail
state_dir="/opt/myapp/state"
map_file="/etc/nginx/maps/branches.map"
mkdir -p "$state_dir"
if [ "${1:-}" = "--remove" ]; then
label="$2"
rm -f "$state_dir/$label.upstream"
else
label="$1"
upstream="$2"
echo "$upstream" > "$state_dir/$label.upstream"
fi
echo "map \$branch_label \$branch_upstream {" > "$map_file.tmp"
for f in "$state_dir"/*.upstream; do
[ -e "$f" ] || continue
l=$(basename "$f" .upstream)
u=$(cat "$f")
echo " $l $u;" >> "$map_file.tmp"
done
echo " default 127.0.0.1:9000;" >> "$map_file.tmp"
echo "}" >> "$map_file.tmp"
mv "$map_file.tmp" "$map_file"
Интеграция с Git и CI
Подключите скрипты к событиям в вашей цепочке — при пуше ветки или открытии Pull Request вызывайте add-preview.sh
, при закрытии PR или удалении ветки — remove-preview.sh
. Обязательно передавайте исходное имя ветки для корректной санитаризации. Храните соответствие «исходная ветка → лейбл» в артефактах CI или в вашем state_dir
, если нужно строгое соответствие URL → исходное имя.
SSL/TLS для wildcard поддоменов
Для удобства используйте wildcard‑сертификат на *.dev.example.com. Так один сертификат накроет все превью‑поддомены, и пользователи смогут смотреть стенды по HTTPS без предупреждений. Выпускайте его через наши SSL-сертификаты. Об автоматизации через DNS‑01 мы подробно писали тут: как выпускать wildcard SSL по DNS‑01.
Базовая защита превью‑стендов
Обычно превью не предназначены для публичного индекса. Достаточно базовой аутентификации и запрета индексации:
# во внутреннем server‑блоке превью
location / {
auth_basic "Preview";
auth_basic_user_file /etc/nginx/htpasswd.preview;
add_header X-Robots-Tag "noindex, nofollow" always;
proxy_pass http://$branch_upstream;
}
Файл паролей можно создать через htpasswd
из пакета apache2-utils
. Для автоматизации положите его рядом с конфигурацией и управляйте пользователями из CI.
Отладка и проверка
- Проверяйте DNS:
dig +short feature.dev.example.com
должен показывать IP VDS. - Тестируйте Nginx локально:
curl -H "Host: feature.dev.example.com" http://127.0.0.1/
. - Смотрите
error.log
при 502/404 — чаще всего карта не содержит нужной записи или сервис не запущен.
Особенности и частые грабли
- nginx map и переменные: переменная в
proxy_pass
отключает keepalive к upstream. Для превью это нормально; для тяжёлых стендов генерируйте именованныеupstream
. - Санитаризация имён: не все ветки подходят для DNS. Заменяйте запрещённые символы и ограничивайте длину. Старайтесь избегать двойных дефисов в конце/начале.
- TTL DNS: при большом TTL изменения могут «ехать» минутами из‑за кэшей резолверов.
- Порты и firewall: backend слушает только 127.0.0.1, наружу открыт лишь 80/443 с Nginx.
- WebSocket: не забудьте о
Upgrade
/Connection
и таймаутах. - Статика и кэш: для SPA/API превью достаточно проксирования; дополнительные кэши обычно не нужны, чтобы видеть свежие сборки.
Альтернативы и расширения
Если поддомены не вариант, можно реализовать routing по пути (/preview/branch/
) — это проще в DNS, но сложнее в приложении из‑за абсолютных URL и куки. Ещё вариант — запускать превью в контейнерах и публиковать их через общий reverse proxy; Nginx map
остаётся центром маршрутизации.
Пошаговый план внедрения
- Завести дев‑домен и настроить wildcard DNS на IP VDS.
- Установить Nginx, включить конфиги
map
и сервер для *.dev.example.com, поднять заглушку 404. - Сделать шаблонный systemd‑юнит для приложения и скрипты add/remove.
- Интегрировать скрипты в CI: on push/open PR →
add-preview
, on close/delete →remove-preview
. - Подключить wildcard TLS, включить basic auth и
X-Robots-Tag
. - Обкатать на тестовых ветках, замерить время развёртывания и стабилизировать пайплайн.
Итоги
Комбинация wildcard DNS, Nginx map
и простых скриптов даёт предсказуемые превью‑стенды: каждый коммит в ветке быстро получает собственный поддомен, а маршрутизация остаётся прозрачной и управляемой. Такой подход масштабируется на десятки веток без тяжёлых инструментов, а при росте требований легко расширяется до именованных upstream, контейнеров и дополнительных политик безопасности. Для команд разработки это быстрый способ ускорить ревью и сократить «не воспроизводится у меня локально» — всё открывается по стабильному URL.