Почти любой гайд по деплою 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. Это не менеджер процессов рядом с веб-сервером, а модуль внутри него.
браузер
│
▼
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-окружения, иначе бинарник не подхватит нужные библиотеки.
# Подключаемся к хостингу
ssh username@username.beget.tech
# Обязательно переходим в docker-окружение
ssh localhost -p 222
# приглашение станет вида: (docker) username@server: [0]~
# Смотрим версию ОС — от неё зависит, какую сборку брать
cat /etc/os-releaseДальше развилка. На Ubuntu 22.04 годится официальная сборка с nodejs.org:
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.
Запоминаем путь — он понадобится в конфиге:
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":
// 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 приходит, а стилей нет и гидратация не происходит.
Итоговый состав того, что уезжает на сервер:
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. Структура каталогов на сервере
/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 попадает ровно то, что и так предназначено для публичной раздачи, остальное лежит уровнем выше и снаружи недостижимо.
cd ~/mysite
rm -rf public_html
ln -s app/public public_html
ls -la # public_html -> app/publicШаг 5. Конфигурация .htaccess
Файл кладётся в папку сайта, рядом с public_html, а не внутрь него:
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 |
PassengerAppType | node |
PassengerStartupFile | Точка входа относительно PassengerAppRoot |
PassengerAppEnv | production. Если Apache ответит Invalid command — директива запрещена на этом сервере, просто уберите строку |
Блок с заголовками кеширования не обязателен, но _next/static содержит хеш в имени файла и иммутабелен по определению — не выставить ему годовой срок жизни было бы расточительством.
Пути можно и не писать руками:
cd ~/mysite
echo "PassengerNodejs $(which node)" > .htaccess
echo "PassengerAppRoot $(realpath app)" >> .htaccess
echo "PassengerAppType node" >> .htaccess
echo "PassengerStartupFile server.js" >> .htaccessШаг 6. Запуск и перезапуск
Запускать руками нечего: Passenger поднимет приложение на первом HTTP-запросе. Единственный механизм управления жизненным циклом — файл-маркер:
mkdir -p ~/mysite/app/tmp
touch ~/mysite/app/tmp/restart.txtPassenger сравнивает mtime этого файла и при изменении перезапускает приложение. touch после каждого деплоя — обязательный шаг, иначе в памяти останется старый код.
Скрипт деплоя
Собираем всё в один скрипт. Логика: собрать локально, сформировать бандл, залить rsync, дёрнуть restart.txt.
#!/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. Лечится подменой бинарников при сборке бандла:
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:
tail -n 100 ~/logs/*error*console.log в error-логе — нормально, так устроен Passenger. Отдельного лога приложения нет.
Проверка запуска в изоляции. Когда сайт отдаёт 500, а в логе невнятно, самый быстрый способ — запустить приложение руками из docker-окружения:
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 к главной странице. Костыль, но рабочий и бесплатный.