Как развернуть Node.js-приложение: pm2 и nginx

Рабочая схема для одного сервера: Node.js под управлением pm2, перед ним nginx с HTTPS от Let's Encrypt.

Автор: Опубликовано: Обновлено: 3 мин чтения

Схема ниже подходит для VPS на Ubuntu или Debian: приложение на Node.js слушает локальный порт, pm2 держит его запущенным и поднимает после перезагрузки, nginx принимает трафик на 80 и 443 портах, терминирует TLS и проксирует запросы в Node. Во всех примерах домен example.com, приложение слушает порт 3000.

Установка Node.js

Версия Node из стандартного репозитория дистрибутива обычно отстаёт на одну-две мажорные версии. Есть два нормальных способа поставить актуальную LTS.

Через менеджер версий (nvm, fnm). Node ставится в домашний каталог пользователя, версии легко переключать, sudo для npm install -g не нужен. Установщик nvm берите из README репозитория nvm-sh/nvm, затем:

nvm install --lts
nvm alias default 'lts/*'
node -v

Через репозиторий NodeSource. Node ставится системно в /usr/bin, обновляется вместе с остальными пакетами через apt:

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

Цифра в setup_24.x – мажорная версия. Проверьте на nodejs.org, какая ветка сейчас в статусе Active LTS, и подставьте её.

Приложение лучше запускать от отдельного непривилегированного пользователя, например deploy, и держать код в /var/www/app или /home/deploy/app.

Зависимости и переменные окружения

На сервере зависимости ставятся строго по package-lock.json:

cd /var/www/app
npm ci --omit=dev

npm ci удаляет node_modules и ставит ровно те версии, что записаны в lock-файле. Если package.json и lock-файл расходятся, команда завершится ошибкой, и это полезно: на прод не уедут неожиданные версии. --omit=dev пропускает devDependencies (линтеры, тестовые фреймворки, сборщики). Если сборка фронтенда идёт на сервере, сначала выполните npm ci, затем npm run build, а потом npm prune --omit=dev.

Секреты (пароль к базе, API-ключи) хранятся в файле .env рядом с приложением, с правами 600, и не попадают в git. Начиная с Node 20.12 файл можно прочитать без сторонних пакетов:

process.loadEnvFile(); // читает .env из текущего каталога, бросает ошибку, если файла нет
const port = Number(process.env.PORT) || 3000;

На старых версиях то же делает пакет dotenv: require("dotenv").config().

Запуск через pm2

npm install -g pm2
pm2 start server.js --name app

Для постоянной конфигурации удобнее ecosystem.config.js в корне проекта:

module.exports = {
  apps: [
    {
      name: "app",
      script: "./server.js",
      cwd: "/var/www/app",
      instances: 2,
      exec_mode: "cluster",
      max_memory_restart: "300M",
      env: {
        NODE_ENV: "production",
        PORT: 3000,
      },
    },
  ],
};
pm2 start ecosystem.config.js
pm2 save
pm2 startup

pm2 save записывает текущий список процессов в ~/.pm2/dump.pm2. pm2 startup печатает команду с sudo, которую нужно скопировать и выполнить: она создаёт systemd-юнит, и после перезагрузки сервера pm2 восстановит сохранённый список. После любого изменения состава процессов снова выполняйте pm2 save.

Повседневные команды:

pm2 ls                         # список процессов, статус, память, число рестартов
pm2 logs app --lines 100       # последние 100 строк логов и дальше в реальном времени
pm2 reload app                 # перезапуск без простоя в cluster mode
pm2 restart app --update-env   # перезапуск с переменными из текущего окружения shell
pm2 install pm2-logrotate      # ротация логов, иначе ~/.pm2/logs растёт бесконечно

pm2 запоминает окружение процесса на момент первого запуска. Если вы поменяли переменную в shell или в env ecosystem-файла, обычный pm2 restart её не подхватит, нужен флаг --update-env. Переменные из .env приложение перечитывает само при каждом старте, поэтому для них хватает обычного рестарта.

nginx как обратный прокси

Node должен слушать только локальный интерфейс, чтобы порт 3000 не был доступен снаружи:

app.listen(port, "127.0.0.1");

Конфиг /etc/nginx/sites-available/example.com:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    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 $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Host передаёт исходный домен, без него Express увидит 127.0.0.1:3000. X-Forwarded-For несёт IP клиента, X-Forwarded-Proto – схему (http или https). map и заголовки Upgrade/Connection нужны для WebSocket; если их нет в приложении, эти строки можно убрать.

trust proxy в Express

За прокси Express по умолчанию игнорирует заголовки X-Forwarded-*: req.ip равен 127.0.0.1, req.protocol всегда http, req.secure всегда false. От этого ломаются rate limiting по IP, логи, cookie с флагом secure в express-session и любые редиректы, которые строят абсолютный URL.

app.set("trust proxy", 1);

Число 1 означает «доверять одному прокси перед приложением», то есть nginx. Значение true доверяет всей цепочке X-Forwarded-For, и клиент сможет подставить в заголовок любой IP. Если перед nginx стоит ещё CDN или балансировщик, ставьте 2 или перечислите их адреса.

HTTPS через certbot и редиректы

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run

Certbot получит сертификат Let's Encrypt на оба имени, допишет в конфиг блок listen 443 ssl и редирект с HTTP. Автопродление ставится вместе с пакетом.

Дальше стоит оставить один канонический адрес: все варианты (http, www, дополнительные домены) отдают 301 на https://example.com. Итоговая структура конфига:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name www.example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    return 301 https://example.com$request_uri;
}
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    location / {
        proxy_pass http://127.0.0.1:3000;
        # те же proxy_set_header, что выше
    }
}

Дополнительный домен (например, старый example.net) добавляется в server_name первого и второго блоков, но для редиректа по HTTPS сертификат должен покрывать и его: перевыпустите сертификат с дополнительными -d. HTTP/2 в nginx 1.25.1 и новее включается директивой http2 on; внутри блока server, в более старых версиях – параметром listen 443 ssl http2. Версию покажет nginx -v.

Код 301 сообщает поисковикам, что адрес переехал навсегда, и они склеивают сигналы со старых URL на канонический. Что ещё настроить на стороне Express для поисковиков, разобрано в статье SEO для сайта на Express.

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

Зачем pm2, если есть systemd?

systemd тоже умеет перезапускать Node.js-процесс и поднимать его после перезагрузки. pm2 добавляет cluster mode с перезапуском без простоя (pm2 reload), просмотр логов и метрик одной командой и удобное управление несколькими приложениями. Для одного простого сервиса хватит и unit-файла systemd.

Почему req.ip в Express за nginx всегда 127.0.0.1?

Express видит соединение от nginx. Нужно передавать заголовок X-Forwarded-For в nginx (proxy_set_header) и включить app.set("trust proxy", 1), тогда req.ip возьмёт адрес клиента из заголовка.

Нужно ли продлевать сертификат certbot вручную?

Нет. Пакет certbot ставит systemd-таймер или cron-задачу, которая дважды в сутки проверяет сертификаты и продлевает те, что истекают в ближайшие 30 дней. Проверить, что продление сработает, можно командой sudo certbot renew --dry-run.