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

URL: https://dmeshcheryakov.ru/blog/nextjs-beget-passenger/
Раздел: NextJS
Теги: NextJS, DevOps, Beget, Deploy, Passenger
Опубликовано: 2026-09-30
Обновлено: 2026-09-07
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

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

Почти любой гайд по деплою 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, читайте [статью про деплой на свой сервер](/blog/nextjs-vps-deploy) — здесь про другое.

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

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

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

Beget использует связку Apache + [Phusion Passenger](https://www.phusionpassenger.com/). Это не менеджер процессов рядом с веб-сервером, а модуль внутри него.

```
браузер
   │
   ▼
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`, ссылки на них есть в [базе знаний](https://beget.com/ru/kb/how-to/web-apps/node-js). 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 приходит, а стилей нет и гидратация не происходит.

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

```
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 попадает ровно то, что и так предназначено для публичной раздачи, остальное лежит уровнем выше и снаружи недостижимо.

```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` |
| `PassengerAppType` | `node` |
| `PassengerStartupFile` | Точка входа относительно `PassengerAppRoot` |
| `PassengerAppEnv` | `production`. Если 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-тарифе он важнее, чем размер бандла.