Я держал проект на Next.js на Vercel почти три месяца и уехал на обычный VPS за 990 рублей в месяц. Главное, чего боялся: потерять то, за что Vercel любят. Сборка, которая не трогает живой сайт. Переключение без простоя. Откат одной кнопкой. Под катом схема, которая всё это повторяет на одном сервере без Kubernetes, Docker и прочей тяжёлой артиллерии, и список граблей, на каждую из которых я наступил.
Почему вообще уехал
Причин было четыре, и ни одна не про деньги.
Первая: бесплатный план Vercel включает автоматическую защиту от ботов. Весь мой трафик шёл через прокси в России, то есть с одного IP. Защита периодически принимала это за атаку и отдавала страницу проверки. Люди в браузере её проходили незаметно, а поисковые роботы получали 403. В Search Console копились ошибки обхода, отключить защиту на бесплатном плане нельзя.
Вторая: одна точка отказа. Однажды ночью прокси перезагрузили, и сайт пролежал девять часов. Про это будет отдельный текст.
Третья: скорость. Цепочка «Россия, прокси, США» давала около 2 секунд на ответ. С сервера в Петербурге стало 0,5 секунды снаружи и 53 миллисекунды локально.
Четвёртая: бесплатный план Vercel запрещает коммерческое использование. Проект коммерческий. Жить с мыслью, что его могут отключить без предупреждения, надоело.
Что хотелось сохранить
Я выписал, чем реально пользовался на Vercel:
-
Сборка идёт отдельно, прод в это время работает на старой версии.
-
Переключение на новую версию мгновенное.
-
Если новая версия не поднялась, прод не трогается.
-
Можно откатиться на любую из последних сборок.
-
История деплоев.
Автопревью для каждой ветки и CDN по всему миру мне были не нужны. Если вам нужны, дальше читать можно, но честно предупреждаю: эту схему они не заменят.
Схема
Идея старая как мир: blue/green. Две копии приложения на одном сервере, на разных портах. Одна обслуживает трафик, вторая ждёт. При деплое новая версия поднимается в свободном слоте, проверяется, и только потом на неё переключается веб‑сервер.
Caddy (80/443, TLS) │ import upstream.caddy │ ┌─────────────┴─────────────┐ 127.0.0.1:3001 127.0.0.1:3002 слот blue слот green (сейчас живой) (ждёт следующего деплоя)
Раскладка каталогов:
/opt/app/ repo/ рабочая копия git releases/ 20260918-090927-ac5f474/ 20260918-154907-44d428f/ blue -> releases/... симлинк на релиз слота blue green -> releases/... симлинк на релиз слота green current -> releases/... .env переменные окружения, права 600 active_port какой порт сейчас живой upstream.caddy одна строка для Caddy releases.log история выкаток
Каждый релиз лежит в отдельной папке с датой и коротким хешем коммита. Храню последние пять.
systemd: один шаблон на оба слота
Вместо двух одинаковых юнитов один шаблон. Имя экземпляра после @ становится именем слота:
# /etc/systemd/system/app@.service[Unit]Description=Next.js app, slot %iAfter=network.target[Service]Type=simpleUser=appWorkingDirectory=/opt/app/%iEnvironmentFile=/opt/app/.envEnvironmentFile=/opt/app/%i.envExecStart=/usr/bin/npm startRestart=alwaysRestartSec=3[Install]WantedBy=multi-user.target
Порт каждого слота лежит в своём файле:
# /opt/app/blue.envPORT=3001# /opt/app/green.envPORT=3002
WorkingDirectory=/opt/app/%i указывает на симлинк слота. Перекинули симлинк на новый релиз, перезапустили слот, и он стартует уже из новой папки.
Caddy: переключение одной строкой
Caddy выпускает сертификаты сам и умеет подключать куски конфига через import. Этим и пользуемся:
example.com { import /opt/app/upstream.caddy}
А в upstream.caddy ровно одна строка:
reverse_proxy 127.0.0.1:3001
Переключение слота выглядит так: переписать эту строку и сделать caddy reload. Reload в Caddy плавный, открытые соединения не рвутся.
Скрипт деплоя
Весь деплой это один bash‑скрипт. Привожу его целиком с комментариями, потому что грабли спрятаны именно в деталях.
#!/bin/bashset -euo pipefailAPP=/opt/appREPO=$APP/repoRELEASES=$APP/releasesREF="${1:-origin/main}"# 1. Забираем код. Скрипт идёт от root, а ключ к репозиторию у пользователя# приложения, поэтому указываем его явно.export GIT_SSH_COMMAND="ssh -i $APP/.ssh/id_ed25519 -o StrictHostKeyChecking=yes"git -C "$REPO" fetch --all --pruneSHA=$(git -C "$REPO" rev-parse --short "$REF")STAMP=$(date +%Y%m%d-%H%M%S)RELEASE="$RELEASES/$STAMP-$SHA"# 2. Выкладываем ревизию в отдельную папку и собираем.# Прод всё это время работает на старом релизе.mkdir -p "$RELEASE"git -C "$REPO" archive "$REF" | tar -x -C "$RELEASE"ln -s "$APP/.env" "$RELEASE/.env"cd "$RELEASE"npm ci --no-audit --no-fundnpm run buildchown -R app:app "$RELEASE"# 3. Какой слот свободенACTIVE=$(cat "$APP/active_port" 2>/dev/null || echo 0)if [ "$ACTIVE" = "3001" ]; then NEW_PORT=3002; NEW=green; OLD=blueelse NEW_PORT=3001; NEW=blue; OLD=green; fi# 4. Поднимаем новую версию в свободном слотеln -sfn "$RELEASE" "$APP/$NEW"systemctl restart "app@$NEW"# 5. Ждём, пока ответит. Не ответил за минуту: прод не трогаем.for i in $(seq 1 30); do code=$(curl -s -o /dev/null -m 3 -w "%{http_code}" "http://127.0.0.1:$NEW_PORT/" || true) [ "$code" = "200" ] && break sleep 2doneif [ "${code:-}" != "200" ]; then echo "новый релиз не отвечает (код ${code:-нет}), прод не тронут" systemctl stop "app@$NEW" || true exit 1fi# 6. Переключаем трафикecho "reverse_proxy 127.0.0.1:$NEW_PORT" > "$APP/upstream.caddy"caddy reload --config /etc/caddy/Caddyfile --adapter caddyfileecho "$NEW_PORT" > "$APP/active_port"ln -sfn "$RELEASE" "$APP/current"echo "$STAMP-$SHA" >> "$APP/releases.log"# 7. Гасим старый слот, чистим старые релизыsleep 5systemctl stop "app@$OLD" 2>/dev/null || truels -1dt "$RELEASES"/*/ | tail -n +6 | xargs -r rm -rf
Пауза в пять секунд перед остановкой старого слота нужна, чтобы запросы, которые он уже принял, успели доработать.
Откат
Откат это тот же пункт 4–6, только с существующим релизом вместо нового. Сборка не нужна, поэтому занимает секунды:
#!/bin/bashset -euo pipefailAPP=/opt/appif [ "${1:-}" = "--list" ] || [ -z "${1:-}" ]; then ls -1t "$APP/releases" exit 0fiRELEASE="$APP/releases/$1"[ -d "$RELEASE" ] || { echo "нет такого релиза"; exit 1; }ACTIVE=$(cat "$APP/active_port")if [ "$ACTIVE" = "3001" ]; then NEW_PORT=3002; NEW=green; OLD=blueelse NEW_PORT=3001; NEW=blue; OLD=green; filn -sfn "$RELEASE" "$APP/$NEW"systemctl restart "app@$NEW"# дальше та же проверка и переключение, что в деплое
Грабли
Вот ради чего стоило писать статью. Каждая из этих штук стоила мне от десяти минут до часа.
npm ci --omit=dev ломает сборку. Первое желание: не ставить dev‑зависимости на прод. Но next build тянет postcss, tailwind и типы, а они обычно лежат в devDependencies. Ставим всё. Если хочется чистоты, собирайте в CI и возите на сервер готовый артефакт.
Git от root не видит ключ деплоя. Скрипт запускается от root, ключ к приватному репозиторию лежит у пользователя приложения. SSH ищет ключ в /root/.ssh и падает с Permission denied (publickey). Лечится явным GIT_SSH_COMMAND с путём к ключу.
Git ругается на «dubious ownership». Репозиторий принадлежит одному пользователю, а git запускает root. Начиная с какой‑то версии git это блокирует. Одна строка: git config --global --add safe.directory /opt/app/repo.
Релиз надо отдать пользователю приложения. Сборка идёт от root, файлы получаются root‑овыми. Приложение работает от своего пользователя и не может писать кэш в .next. Внешне это выглядит как странные ошибки через час после деплоя. Лечится chown -R после сборки.
Caddy не стартует из‑за прав. Caddy работает от пользователя caddy. Если каталог приложения закрыт (700), он не прочитает upstream.caddy. Если лог‑файлы создал root, он не сможет в них писать. Каталог приложения 755, каталог логов принадлежит caddy.
Скрипт деплоя сам себя не обновляет. Эту я нашёл только через три дня. Скрипт делает git fetch и git archive, а в рабочее дерево репозитория ничего не выкладывает. Сам deploy.sh лежит в этом рабочем дереве. Итог: я правлю скрипт в репозитории, пушу, запускаю деплой, а выполняется старая версия скрипта. После правок в каталоге с эксплуатационными скриптами нужно отдельно сделать git checkout origin/main -- ops/.
NEXT_PUBLIC_ переменные вшиваются при сборке. Поменяли в .env что‑то с префиксом NEXT_PUBLIC_, перезапустили службу, а в браузере старое значение. Next подставляет такие переменные в клиентский бандл на этапе next build. Нужен полный деплой, рестарта мало.
Сборка на 4 ГБ памяти может упасть. У меня 4 ГБ, и без swap next build иногда умирал по OOM. Добавил swap на 2 ГБ, с тех пор ни одного падения.
Что получилось по цифрам
|
|
Было на Vercel |
Стало на VPS |
|---|---|---|
|
Ответ главной снаружи |
около 2 с |
около 0,5 с |
|
Ответ локально |
нет данных |
53 мс |
|
Ошибки 4XX у роботов поисковиков за месяц |
были |
ноль |
|
Время деплоя |
меньше минуты |
около 3 мин |
|
Откат |
секунды |
секунды |
|
Стоимость |
0 ₽ |
1140 ₽ в месяц |
Деплой стал заметно дольше: сборка идёт на слабом сервере, а не на машинах Vercel. Мне это не мешает, я выкатываю несколько раз в день, а не каждые пять минут.
Чего я лишился
Автопревью для каждой ветки. Раньше каждый пуш в ветку давал отдельную ссылку. Сейчас для проверки есть отдельный адрес, закрытый от индексации, но он один.
CDN по всему миру. Аудитория у меня в основном в России, для неё свой сервер быстрее. Для глобального продукта я бы так не сделал.
Нулевое администрирование. Теперь обновления системы, место на диске и перезапуски на мне. На практике это полчаса в неделю.
Если у вас проект на Next.js с аудиторией в одной стране и вы упираетесь в ограничения бесплатного Vercel, эта схема закрывает почти всё, чем Vercel ценен, без контейнеров и оркестраторов. Весь механизм это два скрипта и шаблон systemd.
ссылка на оригинал статьи https://habr.com/ru/articles/1086314/