Как выложить Next.js-сайт на дешёвый VPS: nginx, Cloudflare Tunnel и PM2

(анонс для карточки):

У вас есть сайт на Next.js, и вы хотите, чтобы он открывался по вашему домену круглосуточно, а не только пока на ноутбуке запущен npm run dev. Бюджет - VPS за 3-5 долларов в месяц, администрируете сами. Ниже полный путь, как выложить Next.js-сайт на дешёвый VPS: поставить Node.js, собрать проект, поднять процесс под PM2, при необходимости закрыть его слоем nginx и открыть наружу через Cloudflare Tunnel - без единого входящего порта и без ручной возни с сертификатами. Всё собрано из реального деплоя на связке Ubuntu 24.04, 1 vCPU, 2 ГБ ОЗУ - специально маленькой, чтобы видеть, где именно упрёшься.

Коротко. Ставим LTS-версию Node.js из NodeSource, делаем npm ci и npm run build. Сборка обязательно до первого запуска: на старых версиях Next next start без неё падал с ошибкой, на Next 16 сервер поднимется, но страницы будут битые. Запускаем next start напрямую через ecosystem.config.js под PM2 в режиме fork, не через npm start. Публичный вход - Cloudflare Tunnel по config.yml: cloudflared держит исходящее соединение до Cloudflare, поэтому входящие 80/443 остаются закрытыми, certbot не нужен, IP сервера не виден. По умолчанию туннель идёт прямо на приложение; nginx добавляют отдельным слоем под раздачу статики и rate-limit. Автозапуск - pm2 startup плюс pm2 save после каждой правки, проверить настоящим sudo reboot.

Что нужно, чтобы выложить Next.js на VPS

Next.js в продакшене - это не папка со статикой (если только вы не делаете полный статический экспорт, о нём ниже). Это живой процесс Node.js: команда next start поднимает сервер, который отдаёт HTML, гоняет серверные компоненты, API-роуты, middleware и ISR. Значит, нужны три вещи поверх голой Node.js.

  • Менеджер процессов - программа, которая запускает ваш процесс, поднимает его после падения, перезапускает по лимиту памяти и стартует при загрузке сервера. Мы берём PM2.
  • Reverse proxy - веб-сервер, который принимает запрос и передаёт его приложению на localhost, попутно занимаясь заголовками, статикой, таймаутами, ограничением частоты запросов. Это nginx, и он необязателен: по умолчанию туннель ходит прямо на приложение.
  • Публичный вход - способ пустить трафик из интернета на сервер. Классика - открыть порты 80/443 и получить TLS-сертификат. Мы используем Cloudflare Tunnel (named tunnel через config.yml): cloudflared держит исходящее соединение до Cloudflare и портов не открывает вовсе.
  • PM2. Держит next start живым, следит за памятью, ставит автозапуск. Если убрать - после падения или перезагрузки сайт лежит, пока вручную не зайдёте на сервер по SSH.
  • nginx. Отдаёт хешированную статику с диска, ставит заголовки X-Forwarded-*, ограничивает частоту запросов. Если убрать - приложение само отдаёт свою статику и работает, но пропадает раздача файлов с диска, единая точка для заголовков и ограничение частоты запросов.
  • Cloudflare Tunnel. Держит исходящее соединение до Cloudflare, TLS оканчивается на их стороне, IP сервера скрыт. Если убрать - нужно открыть 80/443, выпускать и продлевать сертификат, светить IP сервера.

Развилка: у вас полностью статический сайт. Если в next.config.js стоит output: 'export', приложение собирается в папку out/ и не требует Node в рантайме. Тогда PM2 не нужен: отдавайте out/ напрямую через nginx, а туннель направляйте на nginx. Это самый дешёвый вариант, но без серверного рендеринга по запросу, API-роутов, ISR и middleware. Всё, что ниже, - про обычный SSR-режим с next start.

Потянет ли 1 vCPU и 2 ГБ ОЗУ

Запущенный сайт на Next.js ест немного: next-server поднимается примерно на 120 МБ RSS (в замере pm2 показывал 112 МБ), под нагрузкой выходит на плато 200-220 МБ. Лимит max_memory_restart: '450M' из шага 4 срабатывает задолго до нехватки памяти, то есть служит страховкой от утечки, а не штатным механизмом. Одно ядро спокойно тянет несколько сотен запросов в минуту для сайта средней тяжести.

Тяжёлая часть - сборка. npm run build даже для пустого приложения на Next 16 упирается в пик около 1.2 ГБ RSS (в замере - 1 266 740 КБ). На тарифе с 1 ГБ ОЗУ сборка не проходит даже у простейшего приложения. Поэтому 2 ГБ - практический минимум, и реальному проекту сверх этого нужно одно из трёх:

  • 1-2 ГБ swap (см. шаг 1) - выручает на пике сборки;
  • NODE_OPTIONS=--max-old-space-size=1536 перед npm run build - ограничивает кучу V8, чтобы сборщик не разрастался;
  • сборка на CI и деплой готового каталога .next - тогда на сервере npm run build вообще не запускается.

Сколько RAM

Что будет со сборкой

1 ГБ

сборка падает даже на пустом приложении (пик ~1.2 ГБ); работать можно только с готовым .next из CI

2 ГБ

практический минимум; среднему проекту нужен swap или --max-old-space-size=1536

4 ГБ

комфортно, в том числе для тяжёлого ISR и большого числа зависимостей

Для этой статьи взят тариф с 2 ГБ плюс 2 ГБ swap - сборка среднего проекта проходит, на рантайм остаётся запас.

Шаг 1. Подготовить сервер

Сначала базовая защита: вход по SSH-ключу вместо пароля и файрвол. Это отдельная тема, она разобрана в инструкции по защите свежего VPS. Минимум - разрешить в файрволе SSH и включить его:

sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status

  • allow OpenSSH - профиль ufw, который открывает порт 22 (или ваш нестандартный, если меняли).
  • С Cloudflare Tunnel порты 80 и 443 наружу открывать не нужно - трафик придёт по исходящему соединению туннеля, а на исходящие правила ufw не влияет.

Работать под root постоянно не стоит: заведите обычного пользователя и давайте ему sudo по необходимости. Приложение будет жить в его домашнем каталоге или в /var/www/app.

Сборка Next.js прожорлива по памяти (см. раздел выше), поэтому на 2 ГБ и тем более на 1 ГБ добавьте swap - файл подкачки на диске, который выручает на пиках:

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

  • fallocate -l 2G - резервирует файл на 2 ГБ; chmod 600 закрывает его от чужих.
  • Строка в /etc/fstab подключает swap обратно после перезагрузки.
  • Проверка: swapon --show и free -h - в выводе появляется строка со swap.

Swap на дешёвом диске медленный, это не замена ОЗУ. Он нужен как страховка на время сборки, а не как постоянный режим работы.

Шаг 2. Поставить Node.js

В штатном репозитории Ubuntu лежит устаревшая Node.js. Берём LTS-версию из NodeSource - это официальный репозиторий проекта. Подойдёт 20-я ветка или новее: на проверочном стенде вставал setup_24.x (node v24.21.0), а боевой проект работает на Node 20 LTS - тоже нормально. Возьмите текущую LTS и после установки сверьтесь с node -v.

curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs

  • setup_24.x - подставьте нужную ветку: setup_20.x, setup_22.x или setup_24.x.
  • -fsSL у curl: тихо, без прогресс-бара, с ошибкой при плохом ответе, по редиректам.
  • sudo -E bash - - скрипт добавляет репозиторий NodeSource и его ключ; -E сохраняет переменные окружения.
  • npm ставится вместе с Node, отдельно не нужен.

Что вы должны увидеть:

node -v
npm -v

Первая команда печатает номер версии, например v20.x.x или v24.x.x, вторая - версию npm. Если node -v выдаёт command not found, репозиторий не подключился - перечитайте вывод curl ... | bash.

Шаг 3. Собрать проект - до первого запуска

Заберите код на сервер. Обычно это git clone в /var/www/app (каталог заранее создайте и отдайте вашему пользователю: sudo mkdir -p /var/www/app && sudo chown $USER:$USER /var/www/app).

cd /var/www/app
git clone https://github.com/ваш/репозиторий.git .

Дальше - переменные окружения. Секреты (ключи API, строки подключения) не коммитят в репозиторий; на сервере они лежат в файле .env.production в корне проекта. Их читает сам Next.js из своего рабочего каталога - это важно для шага 4.

nano .env.production
chmod 600 .env.production

Ставим зависимости строго по локфайлу и собираем:

npm ci
npm run build

  • npm ci (clean install) ставит пакеты ровно по package-lock.json, предварительно снося node_modules. Повторяемо и быстрее npm install, который может тихо обновить версии.
  • npm run build запускает next build - компилирует приложение в каталог .next.

Почему именно в этом порядке. На Next 13-14 запуск next start без сборки сразу падал с Error: Could not find a production build in the '.next' directory. На Next 16 поведение мягче и коварнее: сервер поднимается, но отдаёт битые страницы и сыплет ошибками в логах - симптом менее очевиден, результат тот же. Правило простое: сборка всегда до старта и после каждого обновления кода.

Что вы должны увидеть в конце сборки: строку про успешную компиляцию и таблицу маршрутов с пометками типа рендера (Static, SSG, ƒ для динамических). Проверьте, что появился файл .next/BUILD_ID:

cat .next/BUILD_ID

Быстрая ручная проверка, что приложение вообще стартует (порт 3000, потом Ctrl+C):

node_modules/next/dist/bin/next start -p 3000 &
sleep 3
curl -fsS http://127.0.0.1:3000/ >/dev/null && echo "отвечает"
kill %1

  • curl -fsS <URL>: -f вернёт ненулевой код при ответе 4xx/5xx, -sS - тихо, но с текстом ошибки. Удобно для проверок в скриптах.
  • Печать отвечает означает, что сервер поднялся и отдаёт страницу.

Шаг 4. PM2: держим процесс живым

PM2 - менеджер процессов для Node: запускает приложение, поднимает после падения, перезапускает по лимиту памяти, ведёт логи и умеет стартовать всё это при загрузке сервера. Ставится глобально:

sudo npm install -g pm2

Конфиг запуска описываем файлом ecosystem.config.js. Держите его вне каталога релиза (например, в /var/www/), чтобы при выкладке он не менялся. Вот рабочий вариант с боевого проекта, разбор ключей - под блоком:

module.exports = {
apps: [{
name: 'web',
script: '/var/www/app/node_modules/next/dist/bin/next',
args: 'start',
cwd: '/var/www/app',
interpreter: 'node',
exec_mode: 'fork',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '450M',
min_uptime: '30s',
max_restarts: 15,
exp_backoff_restart_delay: 200,
kill_timeout: 8000,
env: {
NODE_ENV: 'production',
PORT: 3000
}
}]
}

  • script - абсолютный путь к бинарнику Next (node_modules/next/dist/bin/next), не симлинк .bin/next и не npm. interpreter: 'node' обязателен - без него PM2 не всегда понимает, чем запускать файл. Про запуск через npm - отдельно ниже, это ключевой момент.
  • cwd - рабочий каталог процесса. Обязателен, но не потому, что его читает PM2. Файлы .env* читает сам Next.js из своего текущего каталога (process.cwd()). Если cwd не задан, PM2 запустит Next из своего каталога, Next не найдёт .env.production и стартует без переменных. Если переменная нужна раньше, чем стартует Next (или самой обёртке), вписывайте её в блок env: {} этого конфига или через env_file.
  • exec_mode: 'fork', instances: 1 - один процесс, без кластеризации. Cluster-режим PM2 плодит копии процесса, каждая со своей памятью; на 2 ГБ это только вредит, а масштабирование Next обычно делают несколькими VPS за балансировщиком.
  • autorestart: true - поднимать процесс после падения. watch: false - не следить за файлами и не перезапускаться на каждое изменение (в проде это только мешает).
  • max_memory_restart: '450M' - если RSS процесса переваливает за 450 МБ, PM2 его перезапускает. Рабочее плато у next-server - 200-220 МБ, так что это чистая страховка от утечки.
  • min_uptime: '30s' + max_restarts: 15 - если процесс живёт меньше 30 секунд и так 15 раз подряд, PM2 перестаёт его дёргать. Ловит битую сборку без бесконечного цикла.
  • exp_backoff_restart_delay: 200 - пауза между перезапусками растёт по экспоненте от 200 мс. Не молотит сервер рестартами, если приложение падает сразу.
  • kill_timeout: 8000 - дать next-server 8 секунд на корректное завершение по SIGTERM, прежде чем PM2 прибьёт его жёстко. Меньше оборванных запросов при перезапуске.
  • env - переменные, которые PM2 добавляет всегда. PORT: 3000 - на этом порту приложение будет слушать.

Почему не npm start. Если в script написать npm, а в args - start, PM2 будет следить за процессом npm - тонкой обёрткой, которая сама почти ничего не делает и висит на 0% CPU. Настоящий next-server запускается как дочерний процесс, и когда он зависает или течёт по памяти, PM2 этого не видит: он смотрит на обёртку. max_memory_restart в этом случае меряет память npm, а не приложения, и не срабатывает вовремя. Поэтому - только прямой запуск бинарника Next.

Запускаем и смотрим состояние:

cd /var/www/app
pm2 start /var/www/ecosystem.config.js
pm2 status
pm2 logs web --lines 30

Что вы должны увидеть: в pm2 status приложение web в статусе online, колонка restart - 0, память около 110-120 МБ. Финальная проверка:

curl -fsS http://127.0.0.1:3000/ >/dev/null && echo "приложение живо"

Если restart растёт на глазах - читайте pm2 logs web: чаще всего это отсутствующая переменная окружения (не задан cwd или нет .env.production) или ошибка в самом коде. Разбор утечек памяти, из-за которых max_memory_restart срабатывает слишком часто, будет в отдельной статье серии (готовится).

Если у вас output: 'standalone'. В этом режиме Next кладёт самодостаточный сервер в .next/standalone/server.js, и запускать надо его: script: '/var/www/app/.next/standalone/server.js'. Рядом с ним должны оказаться каталоги public/ и .next/static/ - Next их не копирует сам, копируйте на этапе сборки. Плюс режима - меньше зависимостей на сервере и ниже расход памяти.

Шаг 5. nginx как reverse proxy (необязательно)

Этот слой можно пропустить. По умолчанию (см. шаг 6) туннель идёт прямо на приложение, и сайт работает. nginx добавляют, когда нужно: раздавать /_next/static/ прямо с диска, ставить общие заголовки, ограничивать частоту запросов (rate-limit), разводить несколько бэкендов.

sudo apt-get install -y nginx

За Cloudflare Tunnel proxy_pass видит адрес 127.0.0.1, и без настройки приложение получит 127.0.0.1 как IP посетителя. Чтобы вернуть настоящий адрес, скажите nginx доверять локальному соединению и брать IP из заголовка Cloudflare. Файл /etc/nginx/conf.d/realip.conf:

set_real_ip_from 127.0.0.1;
real_ip_header CF-Connecting-IP;

  • set_real_ip_from 127.0.0.1 - доверять этому источнику подменять IP (cloudflared подключается с localhost).
  • real_ip_header CF-Connecting-IP - Cloudflare кладёт исходный IP посетителя в этот заголовок, cloudflared его пробрасывает. После этого $remote_addr и X-Real-IP станут реальным адресом.

Основной конфиг /etc/nginx/sites-available/app (правим под sudo):

server {
listen 127.0.0.1:80;
server_name _;
server_tokens off;

# Хешированные ассеты Next.js - прямо с диска, кэш на год
location /_next/static/ {
alias /var/www/app/.next/static/;
access_log off;
expires 365d;
add_header Cache-Control "public, immutable";
}

# Всё остальное - в приложение
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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 https;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 60s;
}
}

  • listen 127.0.0.1:80 - nginx слушает только localhost. cloudflared подключается локально, наружу порт не торчит.
  • server_tokens off - убирает версию nginx из заголовков и страниц ошибок.
  • location /_next/static/ через alias (не root), оба пути с завершающим слэшем. Имена файлов содержат хеш, поэтому expires 365d и immutable безопасны: новая сборка - новые имена. Проверено: отдаёт 200 с Cache-Control: public, immutable, max-age=31536000.
  • proxy_http_version 1.1 - без него keep-alive до приложения не работает, каждый запрос открывает новое соединение.
  • X-Forwarded-Proto https - константой, а не через $scheme: всё, что доходит до nginx, пришло через туннель от Cloudflare, где TLS уже оконечен, значит протокол всегда https. (Отдельная map для одного апстрима не нужна. Если вы дополнительно отдаёте сайт по прямому http - тогда вернитесь к $scheme.)
  • X-Forwarded-Host $host - приложение видит исходный домен, даже если строит ссылки от значения хоста.
  • proxy_read_timeout 60s - сколько ждать ответ приложения. 60 секунд - разумный дефолт; под ISR, длинную регенерацию или стриминг настраивайте под конкретное приложение. Если у приложения есть WebSocket, добавьте проброс заголовков Upgrade/Connection; для обычного SSR это не нужно.

Включаем конфиг, проверяем синтаксис, перезагружаем:

sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

После nginx -t ждём syntax is ok и test is successful. Проверка через nginx (подставьте свой домен в Host):

curl -fsS -o /dev/null -w '%{http_code}\n' -H 'Host: example.com' http://127.0.0.1/

Ожидаем 200 (или тот код, который отдаёт корень вашего сайта). 403 на /_next/static/ обычно значит, что nginx-пользователь не может пройти по каталогам до .next/static - проверьте права на /var/www/app и вложенные папки.

Шаг 6. Cloudflare Tunnel по config.yml

cloudflared - небольшой демон, который открывает исходящее соединение до края сети Cloudflare и держит его. Посетители приходят на Cloudflare, тот передаёт запросы вниз по этому соединению на ваш сервер. Итог: ни одного входящего порта, TLS оканчивается на стороне Cloudflare (свой сертификат не нужен), IP сервера не виден в DNS.

Предусловие: домен обслуживается в Cloudflare (NS-записи указывают на их серверы имён). Ставим cloudflared из репозитория Cloudflare:

sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared

Проверьте версию: cloudflared --version (на боевом стенде - 2026.8.3).

1. Авторизуемся и создаём туннель. Первая команда откроет ссылку для входа в аккаунт Cloudflare и положит сертификат в ~/.cloudflared/. Вторая создаёт именованный туннель и печатает его UUID; учётные данные туннеля лягут в ~/.cloudflared/<UUID>.json.

cloudflared tunnel login
cloudflared tunnel create web

2. Пишем /etc/cloudflared/config.yml. Подставьте свой UUID и домен:

tunnel: <UUID>
credentials-file: /root/.cloudflared/<UUID>.json
ingress:
- hostname: example.com
service: http://localhost:3000
- service: http_status:404

  • ingress - список правил сверху вниз: какой хост на какой локальный сервис отправлять. Последнее правило без hostname обязательно - это ответ по умолчанию.
  • service: http://localhost:3000 - туннель идёт прямо на приложение. Если между ними нужен nginx (шаг 5), пишите http://localhost:80.

3. Создаём DNS-запись - для каждого хоста отдельно. Команда заводит проксируемую (оранжевое облако) CNAME-запись на <UUID>.cfargotunnel.com:

cloudflared tunnel route dns web example.com

4. Оформляем systemd-unit вручную. Файл /etc/systemd/system/cloudflared.service:

[Unit]
Description=cloudflared tunnel
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run
Restart=on-failure
RestartSec=5
User=root

[Install]
WantedBy=multi-user.target

  • --no-autoupdate - не обновляться самовольно; версию держите под контролем через apt.
  • Restart=on-failure + RestartSec=5 - поднимать демон после сбоя с паузой 5 секунд.
  • After/Wants=network-online.target - стартовать после того, как поднялась сеть.

sudo systemctl daemon-reload
sudo systemctl enable --now cloudflared

Что вы должны увидеть:

cloudflared tunnel list
systemctl status cloudflared --no-pager

В списке туннель со свежим подключением, статус сервиса active (running). С вашего ноутбука:

curl -sS -I https://example.com

Ожидаем успешный ответ (код зависит от сайта) и заголовок server: cloudflare. Сайт открывается по https с валидным сертификатом, который вы не выпускали.

Два варианта, между чем выбирать

  • (а) Туннель прямо на приложение (service: http://localhost:3000). nginx не нужен вообще. Приложение само отдаёт свою статику; теряется только выгода раздачи файлов с диска и общих заголовков - сайт при этом работает.
  • (б) Туннель на nginx (service: http://localhost:80, nginx слушает 127.0.0.1:80) и уже он - на приложение. Нужен, когда важны rate-limit, отдача /_next/static/ с диска, несколько бэкендов или общие заголовки в одном месте.

nginx с certbot или Cloudflare Tunnel - что выбрать

Классика: 80/443 + certbot

Cloudflare Tunnel

Входящие порты

открыты 80 и 443

ни одного, кроме SSH

TLS-сертификат

выпускаете и продлеваете сами (certbot)

оконечный TLS на стороне Cloudflare, продление не ваша забота

IP сервера

виден всем, кто резолвит домен

скрыт за Cloudflare

Зависимости в критическом пути

только ваш сервер и удостоверяющий центр

плюс Cloudflare

Требования к домену

подойдёт любой DNS

домен должен обслуживаться Cloudflare

Лишний сетевой хоп

нет

есть, через край Cloudflare

Когда выбрать

нужен сырой TCP, не хотите третью сторону в пути, не на Cloudflare

дешёвый VPS, хотите закрыть веб-порты и спрятать адрес сервера

Для маленького самоуправляемого VPS туннель обычно удобнее: меньше поверхности атаки и ничего не надо продлевать. Если вам важен полный контроль над путём запроса или нужен нестандартный протокол - классический вариант с certbot честнее. Про reverse proxy с автоматическим HTTPS без отдельного certbot есть разбор Caddy.

Файрвол при работе через туннель

Рекомендация по умолчанию: с туннелем не открывайте 80 и 443 вовсе. Наружу торчит только SSH. Проверьте sudo ufw status - в списке должен быть лишь OpenSSH. Исходящее соединение cloudflared при этом работает: ufw по умолчанию фильтрует входящий трафик, не исходящий.

Если по какой-то причине вы всё же открыли порт 80 для проверок по прямому IP, ограничьте его диапазонами Cloudflare - и IPv4, и IPv6:

for ip in $(curl -fsS https://www.cloudflare.com/ips-v4) $(curl -fsS https://www.cloudflare.com/ips-v6); do
sudo ufw allow from "$ip" to any port 80 proto tcp
done
sudo ufw deny 80

  • Списки IP Cloudflare со временем меняются - если полагаетесь на такое ограничение, обновляйте их по расписанию.
  • ufw deny 80 в конце закрывает порт для всех остальных.

Атомарная выкладка новой версии и откат

Правило: не собирать поверх работающего .next. Пока идёт next build, каталог какое-то время неполный; если приложение в этот момент перезапустится, оно поднимется на битой сборке. Поэтому собираем отдельно и переключаемся атомарно - одной операцией, без промежуточного состояния «сборки нет».

# постоянные вещи - вне релизов
sudo mkdir -p /var/www/releases /var/www/shared
# .env.production один раз кладём в /var/www/shared/

REL=/var/www/releases/$(date +%Y%m%d-%H%M)
git clone --depth 1 https://github.com/ваш/репозиторий.git "$REL"
ln -s /var/www/shared/.env.production "$REL/.env.production"
cd "$REL"
npm ci
npm run build

# проверяем сборку на свободном порту, рабочую версию не трогаем
node_modules/next/dist/bin/next start -p 3001 &
sleep 3
curl -fsS http://127.0.0.1:3001/ >/dev/null && echo "новая сборка отвечает"
kill %1

# переключаемся: смена симлинка мгновенна
ln -sfn "$REL" /var/www/app
pm2 restart web --update-env
curl -fsS http://127.0.0.1:3000/ >/dev/null && echo "прод обновлён"

  • ln -sfn меняет симлинк одной операцией - момента, когда каталога /var/www/app нет, не существует.
  • ecosystem.config.js лежит в /var/www/, cwd в нём - /var/www/app, то есть путь через симлинк. После переключения pm2 restart перезапускает процесс уже с новым кодом.

Откат - вернуть симлинк на прошлый релиз:

ln -sfn /var/www/releases/<прошлый> /var/www/app
pm2 restart web

Держите 2-3 последних релиза, старые удаляйте: ls -1dt /var/www/releases/* | tail -n +4 | xargs rm -rf.

Лёгкий способ: атомарная подмена только .next

Если разводить релиз-каталоги не хочется, а код и node_modules меняются редко, подменяйте только .next. Соберите в отдельном клоне /var/www/app-build, потом:

cd /var/www/app
cp -r /var/www/app-build/.next .next.new
mv .next .next.prev && mv .next.new .next
pm2 restart web

mv в пределах одной файловой системы атомарен - каталога .next без содержимого не будет ни на миг. Откат: mv .next .next.bad && mv .next.prev .next && pm2 restart web.

Про простой при перезапуске

pm2 reload обещает перезапуск без разрыва, но в режиме fork с одним процессом это не работает: в замере во время reload из 57 запросов 11 вернули connection refused, провал длился около секунды. Для настоящего нуля простоя нужен режим cluster с instances: 2 и выше за nginx, либо второй экземпляр приложения за балансировщиком. Если короткий провал на выкладке допустим - оставайтесь на fork и pm2 restart, это проще и предсказуемее.

Автозапуск после перезагрузки

nginx и cloudflared работают как systemd-сервисы и стартуют при загрузке сами. Проверка: systemctl is-enabled nginx cloudflared - оба отвечают enabled.

PM2 надо настроить отдельно, два шага:

pm2 startup
# выведет готовую команду, например:
# sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u deploy --hp /home/deploy
# скопируйте и выполните её
pm2 save

  • pm2 startup ставит systemd-unit pm2-<пользователь>.service (под root это pm2-root.service, команда pm2 startup systemd -u root --hp /root). Unit при загрузке вызывает pm2 resurrect.
  • pm2 save записывает текущий список процессов в ~/.pm2/dump.pm2. resurrect поднимает ровно то, что было в последнем pm2 save. Отсюда правило: делайте pm2 save после каждого изменения ecosystem.config.js или набора процессов, иначе ребут вернёт устаревшее состояние или ничего.

Проверяем перезагрузкой, не на словах:

sudo reboot
# через минуту переподключаемся
pm2 status
curl -sS -I https://example.com

pm2 status показывает приложение online со свежим uptime и restart 0, публичный адрес открывается. На боевом стенде это совпало один в один. Тонкости - PATH в unit, порядок старта сервисов, задержки перед поднятием сети - разберём отдельной статьёй серии (готовится).

Что пошло не так -> причина -> что делать

  • Сразу после старта - 500 или «пустые» страницы, в логах жалобы на .next. Причина - next start запустили до npm run build (на Next 13-14 была явная ошибка Could not find a production build, на Next 16 сервер просто поднимается на неполной сборке). Чинится - npm run build, убедиться, что есть .next/BUILD_ID, затем pm2 restart web.
  • Приложение не видит переменные из .env.production. Причина - не задан cwd, и Next ищет .env-файлы в своём рабочем каталоге, где их нет. Чинится - прописать cwd в ecosystem.config.js, затем pm2 delete web && pm2 start ecosystem.config.js; если переменная нужна ещё до старта Next - вписать её в блок env.
  • После перезагрузки сервера сайт не поднялся. Причина - не сделан pm2 save (или сделан до последней правки конфига), либо команду из pm2 startup не выполнили под sudo. Чинится - выполнить оба шага, сделать pm2 save, перезагрузиться ещё раз для проверки.
  • 502 Bad Gateway от nginx. Причина - процесс на :3000 не отвечает: обычно упал при сборке из-за нехватки памяти или из-за ошибки в коде (рантайм-плато 200-220 МБ до лимита 450 МБ не дотягивает). Чинится - pm2 logs web, dmesg | grep -i oom; выносить сборку с сервера, добавить swap, чинить код.
  • 521 от Cloudflare. Причина - на локальном порту из ingress никто не слушает: лёг pm2 или nginx. Чинится - pm2 status, systemctl status nginx cloudflared, ss -ltnp.
  • 522 от Cloudflare. Причина - сервер отвечает слишком долго: упор в CPU или память, слишком короткий таймаут. Чинится - top, pm2 logs web; поднять proxy_read_timeout под конкретный ответ, разобраться с нагрузкой.
  • 523 от Cloudflare. Причина - в ingress неверный service, порт или недоступное локальное имя. Чинится - сверить config.yml с реально слушающим портом (ss -ltnp).
  • В pm2 status растёт счётчик restart. Причина - битая сборка или нет обязательной переменной; min_uptime + max_restarts останавливают цикл. Чинится - pm2 logs web, устранить причину, pm2 restart web.
  • Бесконечный редирект http -> https. Причина - приложение считает соединение незащищённым. Чинится - если туннель идёт через nginx, слать X-Forwarded-Proto https; если прямо на приложение - включить в нём доверие к прокси-заголовкам (trustHost и аналоги).

FAQ

Как задеплоить Next.js на VPS?

Поставьте LTS-версию Node.js, скопируйте код, выполните npm ci и npm run build. Запустите next start под менеджером процессов (PM2 или systemd), чтобы приложение переживало падения и перезагрузки. Публичный доступ дайте через Cloudflare Tunnel по config.yml без открытых портов; nginx добавляйте отдельным слоем, если нужна раздача статики с диска, rate-limit или несколько бэкендов.

Next.js: PM2 или systemd?

Оба держат процесс живым и стартуют его при загрузке. PM2 быстрее настроить, у него из коробки max_memory_restart, растущие паузы между перезапусками, автозапуск и удобные логи - это выручает, когда приложение подтекает по памяти. systemd - штатный механизм Linux без глобального npm-пакета, его берут, когда хотят меньше зависимостей. Мешать оба не нужно: PM2 сам ставит один unit через pm2 startup.

Как настроить nginx reverse proxy для Next.js?

Один server-блок: в location / - proxy_pass http://127.0.0.1:3000 и заголовки Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host; отдельный location /_next/static/ с alias на .next/static и кэшем на год. За Cloudflare добавьте real_ip_header CF-Connecting-IP, иначе приложение увидит вместо посетителя 127.0.0.1. Проверьте конфиг через nginx -t и перезагрузите nginx.

Можно ли использовать Cloudflare Tunnel вместо nginx?

Да, и это способ по умолчанию: ingress в config.yml указывает прямо на http://localhost:3000. nginx добавляют отдельным слоем, когда нужна раздача статики с диска, rate-limit, общие заголовки или несколько бэкендов. Без nginx приложение само отдаёт свою статику, и сайт работает - вы теряете только выгоды этого слоя, не работоспособность.

Хватит ли 1 ГБ ОЗУ для Next.js?

Нет. Сборка даже пустого приложения на Next 16 упирается в пик около 1.2 ГБ RSS, так что 1 ГБ мало даже для простейшего приложения. 2 ГБ - практический минимум; реальному проекту нужен swap, NODE_OPTIONS=--max-old-space-size=1536 или сборка на CI с деплоем готового .next. Запущенный сайт скромнее: около 120 МБ при старте, 200-220 МБ под нагрузкой.

Почему Next.js падает после перезагрузки сервера?

Процесс запущен под PM2, но состояние не сохранено. При загрузке unit pm2-<user>.service вызывает pm2 resurrect и поднимает то, что было в последнем pm2 save. Выполните напечатанную команду из pm2 startup под sudo, делайте pm2 save после каждой правки конфига и проверьте настоящим sudo reboot. nginx и cloudflared поднимаются сами, если их unit'ы enabled.

Нужен ли certbot с Cloudflare Tunnel?

Нет. TLS оканчивается на стороне Cloudflare, а от cloudflared до приложения (или до nginx) трафик идёт по localhost. Сертификат на сервер вы не ставите и не продлеваете. Если позже откроете прямой доступ по 443 в обход туннеля - тогда certbot понадобится.

Коротко

  • Сборка всегда до первого next start и после каждого обновления кода. На Next 16 без неё сервер поднимется, но страницы будут битые.
  • В PM2 запускайте next start напрямую, абсолютным путём к node_modules/next/dist/bin/next, с interpreter: 'node'. Через npm start PM2 следит за обёрткой, а не за next-server, и max_memory_restart меряет не то.
  • cwd обязателен: .env-файлы читает сам Next из своего рабочего каталога, не PM2.
  • 1 ГБ ОЗУ не хватит даже под сборку пустого приложения (пик ~1.2 ГБ). 2 ГБ - минимум, дальше swap, --max-old-space-size или сборка на CI.
  • Запущенный next-server - ~120 МБ при старте, 200-220 МБ под нагрузкой; max_memory_restart 450M - страховка от утечки.
  • Туннель по умолчанию идёт прямо на приложение :3000; nginx - опциональный слой для статики с диска, rate-limit и общих заголовков.
  • Cloudflare Tunnel = никаких входящих портов, никакого certbot, скрытый IP; цена - Cloudflare в критическом пути и лишний хоп.
  • Выкладка атомарно: новый релиз в отдельном каталоге плюс переключение симлинка (или mv каталога .next в пределах одной ФС). Держите прошлый релиз для отката.
  • pm2 reload/restart в fork с одним процессом = провал около секунды. Ноль простоя - cluster с instances от 2 за nginx.
  • Автозапуск = pm2 startup + pm2 save после каждой правки, проверенные настоящим reboot.

Что дальше

PUBLISHED
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU