Next.js без Vercel: деплой на один VPS с переключением без простоя и откатом за секунды

в 14:51, , рубрики: Caddy, nextjs, systemd, Vercel, vercel альтернатива, vercel россия, vps, деплой

Я держал проект на Next.js на Vercel почти три месяца и уехал на обычный VPS за 990 рублей в месяц. Главное, чего боялся: потерять то, за что Vercel любят. Сборка, которая не трогает живой сайт. Переключение без простоя. Откат одной кнопкой. Под катом схема, которая всё это повторяет на одном сервере без Kubernetes, Docker и прочей тяжёлой артиллерии, и список граблей, на каждую из которых я наступил.

Почему вообще уехал

Причин было четыре, и ни одна не про деньги.

Первая: бесплатный план Vercel включает автоматическую защиту от ботов. Весь мой трафик шёл через прокси в России, то есть с одного IP. Защита периодически принимала это за атаку и отдавала страницу проверки. Люди в браузере её проходили незаметно, а поисковые роботы получали 403. В Search Console копились ошибки обхода, отключить защиту на бесплатном плане нельзя.

Вторая: одна точка отказа. Однажды ночью прокси перезагрузили, и сайт пролежал девять часов. Про это будет отдельный текст.

Третья: скорость. Цепочка «Россия, прокси, США» давала около 2 секунд на ответ. С сервера в Петербурге стало 0,5 секунды снаружи и 53 миллисекунды локально.

Четвёртая: бесплатный план Vercel запрещает коммерческое использование. Проект коммерческий. Жить с мыслью, что его могут отключить без предупреждения, надоело.

Что хотелось сохранить

Я выписал, чем реально пользовался на Vercel:

  1. Сборка идёт отдельно, прод в это время работает на старой версии.

  2. Переключение на новую версию мгновенное.

  3. Если новая версия не поднялась, прод не трогается.

  4. Можно откатиться на любую из последних сборок.

  5. История деплоев.

Автопревью для каждой ветки и 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 %i
After=network.target

[Service]
Type=simple
User=app
WorkingDirectory=/opt/app/%i
EnvironmentFile=/opt/app/.env
EnvironmentFile=/opt/app/%i.env
ExecStart=/usr/bin/npm start
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

Порт каждого слота лежит в своём файле:

# /opt/app/blue.env
PORT=3001

# /opt/app/green.env
PORT=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/bash
set -euo pipefail

APP=/opt/app
REPO=$APP/repo
RELEASES=$APP/releases
REF="${1:-origin/main}"

# 1. Забираем код. Скрипт идёт от root, а ключ к репозиторию у пользователя
#    приложения, поэтому указываем его явно.
export GIT_SSH_COMMAND="ssh -i $APP/.ssh/id_ed25519 -o StrictHostKeyChecking=yes"
git -C "$REPO" fetch --all --prune
SHA=$(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-fund
npm run build
chown -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=blue
else 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 2
done
if [ "${code:-}" != "200" ]; then
  echo "новый релиз не отвечает (код ${code:-нет}), прод не тронут"
  systemctl stop "app@$NEW" || true
  exit 1
fi

# 6. Переключаем трафик
echo "reverse_proxy 127.0.0.1:$NEW_PORT" > "$APP/upstream.caddy"
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
echo "$NEW_PORT" > "$APP/active_port"
ln -sfn "$RELEASE" "$APP/current"
echo "$STAMP-$SHA" >> "$APP/releases.log"

# 7. Гасим старый слот, чистим старые релизы
sleep 5
systemctl stop "app@$OLD" 2>/dev/null || true
ls -1dt "$RELEASES"/*/ | tail -n +6 | xargs -r rm -rf

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

Откат

Откат это тот же пункт 4–6, только с существующим релизом вместо нового. Сборка не нужна, поэтому занимает секунды:

#!/bin/bash
set -euo pipefail
APP=/opt/app

if [ "${1:-}" = "--list" ] || [ -z "${1:-}" ]; then
  ls -1t "$APP/releases"
  exit 0
fi

RELEASE="$APP/releases/$1"
[ -d "$RELEASE" ] || { echo "нет такого релиза"; exit 1; }

ACTIVE=$(cat "$APP/active_port")
if [ "$ACTIVE" = "3001" ]; then NEW_PORT=3002; NEW=green; OLD=blue
else NEW_PORT=3001; NEW=blue; OLD=green; fi

ln -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.

Автор: DenVibeCode

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js