NextJS на виртуальном хостинге Beget — Passenger вместо PM2

Развернуть Next.js на shared-хостинге Beget можно, но не по гайдам для VPS. Разбираем Passenger, standalone-сборку, чего в ней не хватает и где это ломается.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 30 сентября 2026 г.📖 12 мин чтения

Почти любой гайд по деплою Next.js на российский хостинг начинается одинаково: ставим PM2, пишем proxy_pass на 127.0.0.1:3000, перезагружаем nginx. На виртуальном хостинге Beget не работает ни один из трёх шагов — ни постоянно висящего процесса, ни своего конфига nginx там нет и быть не может.

Работающий способ существует, он документирован, и он другой. Ниже — разбор того, как Beget запускает Node-приложения на shared-тарифе, и полная инструкция для Next.js: от установки Node в домашнюю директорию до скрипта деплоя одной командой.

Виртуальный хостинг и «Облако» — это разные продукты

Разграничим сразу, потому что путаница здесь стоит дорого.

Beget Cloud (VPS) — обычная виртуальная машина. Вы root, там работают PM2, systemd, Docker и любые гайды из интернета. Если у вас VPS, читайте статью про деплой на свой сервер — здесь про другое.

Виртуальный хостинг — общий сервер, где ваш аккаунт ограничен домашней директорией. Управлять процессами вы не можете: их поднимает и гасит веб-сервер. Именно об этом сценарии дальше.

Всё, что вы могли читать про Next.js + PM2 + reverse proxy, относится к первому случаю. Попытка воспроизвести это на shared-тарифе упирается в стену не из-за настроек, а по устройству платформы.

Как shared-хостинг запускает Node

Beget использует связку Apache + Phusion Passenger. Это не менеджер процессов рядом с веб-сервером, а модуль внутри него.

text
браузер
   │
   ▼
nginx Beget ──► Apache + mod_passenger
                   │
                   ├─ файл есть в DocumentRoot? → отдаёт напрямую
                   │     /_next/static/*, /favicon.ico
                   │
                   └─ файла нет → передаёт запрос Node-процессу
                         │
                         ▼
                   server.js (Next.js)

Из этой схемы следуют три вещи, которые ломают привычные представления.

Порт не назначается вами. Passenger подменяет Server.prototype.listen() и выдаёт приложению собственный сокет. Строка .listen(3000) в коде остаётся, но фактический порт вы не контролируете и знать не должны. Соответственно, proxy_pass на фиксированный порт нечему адресовать.

Процесс живёт по требованию. Passenger поднимает приложение на первом HTTP-запросе и гасит после простоя. PM2 здесь не просто не нужен — он не сможет удержать процесс, даже если вы его установите.

Веб-сервер видит только DocumentRoot. Всё, что физически лежит в директории сайта, Apache отдаёт напрямую, минуя Node. Это и оптимизация, и источник дыры в безопасности, если положить туда корень проекта целиком.

💡 Совет

Что получится в итоге: Next.js с SSR и ISR на shared-тарифе, статика отдаётся Apache в обход Node, деплой одной командой с локальной машины, перезапуск через touch.

Шаг 1. Node.js в домашнюю директорию

Системного Node на виртуальном хостинге нет — вы ставите свой, в ~/.local. Причём собирать и запускать нужно из docker-окружения, иначе бинарник не подхватит нужные библиотеки.

bash
# Подключаемся к хостингу
ssh username@username.beget.tech

# Обязательно переходим в docker-окружение
ssh localhost -p 222
# приглашение станет вида: (docker) username@server: [0]~

# Смотрим версию ОС — от неё зависит, какую сборку брать
cat /etc/os-release

Дальше развилка. На Ubuntu 22.04 годится официальная сборка с nodejs.org:

bash
mkdir -p ~/.local && cd ~/.local
wget https://nodejs.org/download/release/v22.14.0/node-v22.14.0-linux-x64.tar.xz
tar xfv node-v22.14.0-linux-x64.tar.xz --strip 1
rm node-v22.14.0-linux-x64.tar.xz
node -v && npm -v

На Ubuntu 18.04 официальные сборки новее v17.9.1 не запустятся — там старый glibc. Beget выкладывает собственные сборки до v21.7.3, ссылки на них есть в базе знаний. Next.js 16 требует Node ≥ 20.9, так что 18.04 всё ещё проходит по нижней границе, но запас там нулевой.

Ключ --strip 1 обязателен: он распаковывает содержимое архива прямо в ~/.local, а не во вложенную папку, и тогда ~/.local/bin/node оказывается в PATH.

Запоминаем путь — он понадобится в конфиге:

bash
which node
# /home/u/username/.local/bin/node
⚠️ Важно

Только 64-битные сборки. 32-битные бинарники на серверах Beget запускать запрещено правилами. И если в аккаунте включена изоляция сайтов, веб-сервер не увидит ваш Node: нужно открыть общий доступ к каталогу в панели либо сделать chmod 755 ~ ~/.local ~/.local/bin.

Шаг 2. Собирать надо не на хостинге

Инстинктивное желание — сделать git clone, npm ci, npm run build прямо на сервере. На shared-тарифе это плохая идея по трём причинам.

Памяти не хватит. Блог из сотни MDX-статей с подсветкой кода через Shiki на сборке съедает больше гигабайта. Процесс либо падает с ENOMEM, либо его убивают по лимиту — второе неприятнее, потому что в логах остаётся просто оборванный вывод.

node_modules для сборки на сервере не нужны. Полное дерево зависимостей Next.js — это сотни мегабайт, из которых в рантайме используется меньше четверти.

Сборка не воспроизводима. Версия Node на хостинге отличается от локальной, и «у меня работает» превращается в «на сервере другой минорный релиз».

Правильный ответ — output: "standalone":

typescript
// next.config.ts
const nextConfig: NextConfig = {
  output: "standalone",

  experimental: {
    workerThreads: false,  // щадящий режим сборки
    cpus: 1,
  },
};

Next трассирует реальные зависимости и складывает в .next/standalone самодостаточный бандл: свой server.js, минимальные node_modules, серверные чанки. npm install на хостинге не нужен вообще.

Отдельно проверьте, есть ли в проекте нативные зависимости. Если единственный кандидат — sharp (о нём ниже), бандл, собранный на macOS, поедет на Linux без проблем: всё остальное — чистый JS и WASM.

Шаг 3. Чего не хватает в standalone-сборке

Здесь начинаются грабли, на которых спотыкаются все. next build с output: "standalone" не кладёт в бандл две обязательные вещи:

  • .next/static — весь JS и CSS клиента;
  • public/ — фавиконки, изображения, статические файлы.

Это задокументированное поведение, а не баг: Vercel раздаёт эти директории через CDN и в бандле их не ждёт. При self-hosting копировать их обязан деплой-скрипт. Симптом, если забыть, узнаваемый: страницы открываются, HTML приходит, а стилей нет и гидратация не происходит.

Итоговый состав того, что уезжает на сервер:

text
dist-beget/
├── server.js                 ← из .next/standalone, точка входа Passenger
├── package.json
├── node_modules/             ← только рантайм-зависимости
├── content/articles/*.mdx    ← контент, если читается с диска в рантайме
├── .next/
│   ├── server/
│   ├── static/               ← ДОБАВЛЯЕМ вручную
│   └── BUILD_ID
├── public/                   ← станет DocumentRoot-ом
│   ├── favicon.ico, *.svg
│   └── _next/static/         ← копия .next/static для Apache
└── tmp/restart.txt           ← «кнопка» перезапуска Passenger

Два момента в этой структуре неочевидны.

Копия статики в public/_next/static. Next умеет отдавать /_next/static/* сам, но каждый такой запрос будит Node-процесс. Если положить копию в DocumentRoot, Apache отдаёт файлы напрямую и до Passenger дело не доходит. На сайте с сотней чанков разница заметна невооружённым глазом.

Контент, читаемый с диска. Если приложение при рендере обращается к файловой системе — MDX-статьи, JSON-справочники, — Next обычно затягивает эти директории в трассировку, но проверить надо. Забытая папка content/ даёт 500 на всех страницах и ENOENT в логах.

Шаг 4. Структура каталогов на сервере

text
/home/u/username/
├── .local/bin/node                  ← Node из шага 1
└── mysite/
    ├── .htaccess                    ← директивы Passenger
    ├── public_html -> app/public    ← СИМЛИНК, DocumentRoot сайта
    └── app/                         ← PassengerAppRoot
        ├── server.js
        ├── .next/
        ├── node_modules/
        ├── public/
        └── tmp/restart.txt

Центральное решение здесь — public_html как симлинк на app/public.

Соблазн сделать проще — распаковать проект прямо в public_html и указать Passenger на него же. Так делать нельзя: Apache отдаёт из DocumentRoot всё, что там физически лежит. Ваш .env с ключами, исходники, node_modules, .git — всё это станет доступно по прямой ссылке. Проверяется в один запрос и находится сканерами за минуты.

Симлинк на public решает проблему структурно: в DocumentRoot попадает ровно то, что и так предназначено для публичной раздачи, остальное лежит уровнем выше и снаружи недостижимо.

bash
cd ~/mysite
rm -rf public_html
ln -s app/public public_html
ls -la          # public_html -> app/public

Шаг 5. Конфигурация .htaccess

Файл кладётся в папку сайта, рядом с public_html, а не внутрь него:

apache
PassengerNodejs /home/u/username/.local/bin/node
PassengerAppRoot /home/u/username/mysite/app
PassengerAppType node
PassengerStartupFile server.js
PassengerAppEnv production

<IfModule mod_expires.c>
  ExpiresActive On
  <FilesMatch "\.(js|css|woff2|woff|svg|png|jpg|jpeg|webp|avif|ico)$">
    ExpiresDefault "access plus 1 year"
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>
</IfModule>

Разбор директив:

ДирективаЗначение
PassengerNodejsАбсолютный путь к вашему Node — результат which node внутри docker-окружения
PassengerAppRootКорень приложения: папка, где лежат server.js и node_modules
PassengerAppTypenode
PassengerStartupFileТочка входа относительно PassengerAppRoot
PassengerAppEnvproduction. Если Apache ответит Invalid command — директива запрещена на этом сервере, просто уберите строку

Блок с заголовками кеширования не обязателен, но _next/static содержит хеш в имени файла и иммутабелен по определению — не выставить ему годовой срок жизни было бы расточительством.

Пути можно и не писать руками:

bash
cd ~/mysite
echo "PassengerNodejs $(which node)" > .htaccess
echo "PassengerAppRoot $(realpath app)" >> .htaccess
echo "PassengerAppType node" >> .htaccess
echo "PassengerStartupFile server.js" >> .htaccess

Шаг 6. Запуск и перезапуск

Запускать руками нечего: Passenger поднимет приложение на первом HTTP-запросе. Единственный механизм управления жизненным циклом — файл-маркер:

bash
mkdir -p ~/mysite/app/tmp
touch ~/mysite/app/tmp/restart.txt

Passenger сравнивает mtime этого файла и при изменении перезапускает приложение. touch после каждого деплоя — обязательный шаг, иначе в памяти останется старый код.

Скрипт деплоя

Собираем всё в один скрипт. Логика: собрать локально, сформировать бандл, залить rsync, дёрнуть restart.txt.

bash
#!/usr/bin/env bash
set -euo pipefail

BEGET_USER="${BEGET_USER:-username}"
BEGET_HOST="${BEGET_HOST:-username.beget.tech}"
BEGET_HOME="/home/${BEGET_USER:0:1}/$BEGET_USER"
BEGET_SITE_DIR="$BEGET_HOME/mysite"
APP_DIR="$BEGET_SITE_DIR/app"

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUNDLE="$ROOT/dist-beget"

# 1. Сборка
NODE_OPTIONS="--max-old-space-size=4096" npm run build

# 2. Бандл
rm -rf "$BUNDLE" && mkdir -p "$BUNDLE"
cp -R "$ROOT/.next/standalone/." "$BUNDLE/"

# то, что standalone не копирует
mkdir -p "$BUNDLE/.next/static"
cp -R "$ROOT/.next/static/." "$BUNDLE/.next/static/"
mkdir -p "$BUNDLE/public"
cp -R "$ROOT/public/." "$BUNDLE/public/"

# копия статики для Apache
mkdir -p "$BUNDLE/public/_next/static"
cp -R "$ROOT/.next/static/." "$BUNDLE/public/_next/static/"

mkdir -p "$BUNDLE/tmp" && touch "$BUNDLE/tmp/restart.txt"

# 3. Заливка
ssh "$BEGET_USER@$BEGET_HOST" "mkdir -p '$APP_DIR'"
rsync -az --delete "$BUNDLE/" "$BEGET_USER@$BEGET_HOST:$APP_DIR/"

# 4. Симлинк и перезапуск
ssh "$BEGET_USER@$BEGET_HOST" bash -s <<EOF
set -e
cd "$BEGET_SITE_DIR"
[ -L public_html ] || { rm -rf public_html; ln -s app/public public_html; }
touch "$APP_DIR/tmp/restart.txt"
EOF

Два замечания по устройству.

rsync --delete не просто экономит трафик, а гарантирует, что на сервере не останется файлов от предыдущей сборки. Чанки Next.js именуются по хешу содержимого, и без --delete директория static растёт неограниченно, накапливая мусор от всех прошлых деплоев.

rsync и ssh для копирования файлов работают из внешнего SSH, без перехода в docker-окружение: файловая система там общая. Переход ssh localhost -p 222 нужен только для команд, которым требуется Node.

Грабли

Собрал то, на чём реально спотыкаешься, в порядке вероятности встречи.

sharp под чужую платформу. Next тянет в трассировку sharp с бинарником той платформы, где шла сборка. Собрали на Mac — в бандл уехал @img/sharp-darwin-arm64, на Linux бесполезный. Если next/image в проекте не используется, модуль просто не загружается и проблема остаётся незамеченной. Если используется — все оптимизированные картинки отдают 500. Лечится подменой бинарников при сборке бандла:

bash
rm -rf node_modules/@img/sharp-darwin-* node_modules/@img/sharp-libvips-darwin-*
npm install --os=linux --cpu=x64 --libc=glibc sharp

Холодный старт после простоя. Passenger гасит процесс примерно через пять минут без запросов. Следующий посетитель ждёт полного старта Next.js — от двух до пяти секунд. На shared-тарифе PassengerPoolIdleTime не настраивается, поэтому лечится снаружи: планировщик заданий в панели, раз в пять минут curl -s -o /dev/null https://вашдомен.ru/. Костыль, но рабочий и бесплатный.

Права на запись для ISR. Если в проекте есть export const revalidate, Next пишет регенерированные страницы в .next/cache. В домашней директории права есть по умолчанию, но если вы решите вынести приложение в нестандартное место — проверьте. Симптом специфический: страницы отдаются, но никогда не обновляются, а в логах копятся ошибки записи.

308, а не 301. Редиректы из next.config.ts с permanent: true Next отдаёт кодом 308 Permanent Redirect. Для поисковых систем это эквивалент 301, но если вы проверяете миграцию старых URL через curl -I и ждёте 301 — не пугайтесь.

Логи там, где не ищут. Всё, что приложение пишет в stdout и stderr, попадает в error-лог Apache:

bash
tail -n 100 ~/logs/*error*

console.log в error-логе — нормально, так устроен Passenger. Отдельного лога приложения нет.

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

bash
ssh localhost -p 222
cd ~/mysite/app && node server.js

Все ошибки инициализации вывалятся в терминал немедленно.

Где заканчивается shared-хостинг

Схема рабочая, но у неё есть потолок, и стоит понимать, где он.

Динамические маршруты стоят дорого. Каждая страница без ISR будит Node-процесс. Пока это блог с полусотней посетителей в день — неважно. При росте трафика первым делом смотрите вывод next build: маршруты, помеченные ƒ (Dynamic), и есть ваша нагрузка. Часто половина из них становится динамической случайно — из-за одного обращения к headers() или searchParams в разделяемом компоненте.

Генерация OG-картинок — самая тяжёлая операция. next/og поднимает WASM-модули resvg и yoga и рисует PNG. Один вызов — десятки миллисекунд CPU и заметный всплеск памяти. Когда по сайту проходит краулер, дёргая OG-картинку каждой статьи, на shared-тарифе это чувствуется. Если картинки статичны по содержанию — генерируйте их на этапе сборки через generateStaticParams, а не по запросу.

Один процесс — один поток. Passenger на виртуальном хостинге поднимает один экземпляр приложения, PassengerMaxPoolSize вам недоступен. Кластеризации нет, параллелизм ограничен event loop. Для контентного сайта этого достаточно с запасом, для приложения с тяжёлым SSR — нет.

💡 Совет

Ориентир для решения: если сайт статический или ISR-ный, трафик измеряется тысячами визитов в сутки, а тяжёлых вычислений в рантайме нет — виртуальный хостинг закрывает задачу за деньги, несопоставимые с VPS. Как только появляется постоянная фоновая работа, вебсокеты, очереди или нужда в контроле над процессом — пора переезжать.

Итоги

Развернуть Next.js на виртуальном хостинге Beget вполне реально, если перестать искать способ запустить PM2 и принять модель Passenger: процессом управляет веб-сервер, порт назначается за вас, перезапуск делается изменением файла.

Что стоит держать в голове:

Собирайте локально, отправляйте артефакт. Это не только обход лимитов памяти, но и предсказуемость: на сервер уезжает ровно то, что вы проверили у себя. Побочный бонус — деплой перестаёт зависеть от доступности npm-регистри в момент выката.

Проверяйте бандл до заливки. Запустить node server.js из собранного dist-beget и пройтись curl по десятку ключевых маршрутов — минута работы, которая ловит забытую .next/static до того, как её увидят посетители. Локальный npm run dev этот класс ошибок не показывает принципиально: он собирает из исходников и о составе бандла ничего не знает.

DocumentRoot — граница безопасности, а не деталь конфигурации. Симлинк на public вместо распаковки проекта в public_html — то решение, из-за которого потом не приходится объяснять, как исходники и .env оказались в открытом доступе.

Смотрите на карту маршрутов после сборки. Вывод next build со значками ○ ● ƒ — это и есть прогноз нагрузки на хостинг. На shared-тарифе он важнее, чем размер бандла.

Частые вопросы

Почему PM2 не работает на виртуальном хостинге Beget?

Потому что процессами управляет веб-сервер, а не вы. Beget использует Apache с модулем Phusion Passenger: он поднимает приложение на первом HTTP-запросе и гасит после простоя, поэтому PM2 не просто не нужен — он не сможет удержать процесс, даже если вы его установите. Заодно не работает и proxy_pass на фиксированный порт: Passenger подменяет Server.prototype.listen() и выдаёт приложению собственный сокет, поэтому строка listen(3000) в коде остаётся, но фактический порт вы не контролируете.

Почему нельзя собирать Next.js прямо на shared-хостинге?

По трём причинам. Памяти не хватит: блог из сотни MDX-статей с подсветкой кода через Shiki съедает на сборке больше гигабайта, и процесс либо падает с ENOMEM, либо его убивают по лимиту — второе неприятнее, потому что в логах остаётся оборванный вывод. Полное дерево node_modules — сотни мегабайт, из которых в рантайме используется меньше четверти. И сборка не воспроизводима: версия Node на хостинге отличается от локальной. Правильный ответ — output standalone и отправка готового артефакта.

Почему после деплоя standalone-сборки нет стилей?

Потому что next build с output standalone не кладёт в бандл две обязательные вещи: директорию .next/static со всем клиентским JS и CSS и директорию public/ с фавиконками и статикой. Это задокументированное поведение, а не баг: Vercel раздаёт их через CDN и в бандле не ждёт. При self-hosting копировать их обязан деплой-скрипт. Симптом узнаваемый: страницы открываются, HTML приходит, а стилей нет и гидратация не происходит.

Зачем делать public_html симлинком на app/public?

Это граница безопасности, а не деталь конфигурации. Apache отдаёт из DocumentRoot всё, что там физически лежит, поэтому распакованный в public_html проект открывает по прямой ссылке .env с ключами, исходники, node_modules и .git — проверяется в один запрос и находится сканерами за минуты. Симлинк на public решает проблему структурно: в DocumentRoot попадает ровно то, что и так предназначено для публичной раздачи, а остальное лежит уровнем выше и снаружи недостижимо.

Как перезапустить Next.js приложение на Passenger?

Командой touch по файлу tmp/restart.txt внутри PassengerAppRoot. Passenger сравнивает mtime этого файла и при изменении перезапускает приложение — это единственный механизм управления жизненным циклом, и делать это после каждого деплоя обязательно, иначе в памяти останется старый код. Запускать приложение вручную не нужно: Passenger поднимет его на первом HTTP-запросе.

Что делать с холодным стартом после простоя?

Passenger гасит процесс примерно через пять минут без запросов, и следующий посетитель ждёт полного старта Next.js — от двух до пяти секунд. На shared-тарифе параметр PassengerPoolIdleTime не настраивается, поэтому лечится снаружи: планировщик заданий в панели, раз в пять минут запрос curl к главной странице. Костыль, но рабочий и бесплатный.