Перейти к содержимому
·12 мин чтения

Настройка VPS, которая реально работает: Node.js, PM2, Nginx и деплой без даунтайма

Точная настройка VPS для деплоя, которую я использую в продакшене — защита Ubuntu, кластерный режим PM2, обратный прокси Nginx, SSL и деплой-скрипт, который ни разу не подвел. Никакой теории, только то, что работает.

Поделиться:X / TwitterLinkedIn

Этот блог работает на VPS за $10 в месяц. Не Vercel, не AWS, не Kubernetes-кластер, обслуживаемый командой из шести человек. Один Ubuntu-сервер с Nginx, PM2 и bash-скриптом, который деплоит за 30 секунд.

Я пробовал другие пути. Использовал Vercel (отлично, пока не понадобятся cron-задачи, постоянные WebSocket-соединения или просто контроль). Использовал AWS (отлично, если вам нравится проводить полдня в IAM-политиках). Я всегда возвращаюсь к VPS.

Но вот проблема: каждый туториал «деплой на VPS» в интернете останавливается на happy path. Они показывают, как установить Node.js и запустить node server.js, и называют это продакшеном. Потом ваш сервер подвергается SSH-брутфорсу, процесс умирает в 3 часа ночи, потому что никто не настроил менеджер процессов, а ваш SSL-сертификат истек три месяца назад.

Это руководство, которого мне не хватало. Всё здесь проверено в бою — именно эта настройка обслуживает страницу, которую вы сейчас читаете.

Начните с безопасности, а не с кода#

Прежде чем даже думать о Node.js, защитите сервер. Свежие экземпляры VPS — мишени. Автоматизированные боты начинают долбить ваш SSH-порт в течение минут после создания.

Создайте пользователя без root-привилегий#

bash
adduser deploy
usermod -aG sudo deploy

Настройте аутентификацию по SSH-ключам#

На вашей локальной машине:

bash
ssh-copy-id deploy@your-server-ip

Затем полностью отключите аутентификацию по паролю:

bash
sudo nano /etc/ssh/sshd_config
bash
PasswordAuthentication no
PermitRootLogin no
bash
sudo systemctl restart sshd

Если вы пропустите это, то увидите тысячи неудачных попыток входа в auth-логах в течение нескольких дней. Это не паранойя — это обычный вторник в публичном интернете.

Файрвол с UFW#

bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Вот и всё. Четыре правила. Проходит только SSH и веб-трафик.

Fail2Ban#

bash
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

Отредактируйте /etc/fail2ban/jail.local:

ini
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
bash
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

Три неудачные попытки SSH — и вы забанены на час. Я наблюдал, как Fail2Ban блокировал сотни IP за один день. Оно работает.

Автоматические обновления безопасности#

bash
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgrades

Ваш сервер теперь будет автоматически устанавливать патчи безопасности. Одной вещью меньше можно забыть.

Node.js: используйте NVM, а не apt#

Я вижу это в каждом туториале: sudo apt install nodejs. Не делайте так.

Репозитории пакетов Ubuntu поставляют древние версии Node.js. Даже NodeSource PPA отстает. А когда вам нужно переключаться между Node 20 и Node 22 для разных проектов, вы застрянете.

NVM решает это:

bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*

Теперь проверьте:

bash
node -v  # v22.x.x или текущая LTS-версия
npm -v

Неочевидный совет: когда вы устанавливаете глобальные пакеты через NVM (например, PM2), они привязаны к конкретной версии Node. Если вы переключите версию через nvm use, ваши глобальные пакеты исчезнут. Установите дефолт и придерживайтесь его на сервере:

bash
nvm alias default 22

Меня это укусило ровно один раз. Одного раза хватило.

PM2: менеджер процессов, который отрабатывает на все сто#

PM2 — это разница между «задеплоено» и «готово к продакшену». Он обеспечивает управление процессами, кластеризацию, ротацию логов, автоперезапуск при падениях и скрипты запуска. Бесплатно.

Установка и настройка#

bash
npm install -g pm2

Конфигурация экосистемы#

Не запускайте приложения через CLI-флаги. Используйте файл ecosystem.config.js. Он версионируется, воспроизводим и самодокументирован.

javascript
// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "akousa",
      script: "node_modules/.bin/next",
      args: "start -p 3002",
      cwd: "/var/www/akousa.net",
      instances: 2,
      exec_mode: "cluster",
      max_memory_restart: "500M",
      env: {
        NODE_ENV: "production",
        PORT: 3002,
      },
      // Грациозное завершение
      kill_timeout: 5000,
      listen_timeout: 10000,
      wait_ready: false,
      // Логирование
      log_date_format: "YYYY-MM-DD HH:mm:ss Z",
      error_file: "/var/log/pm2/akousa-error.log",
      out_file: "/var/log/pm2/akousa-out.log",
      merge_logs: true,
      // Автоперезапуск при падении
      autorestart: true,
      max_restarts: 10,
      min_uptime: "10s",
      // Не watch в продакшене
      watch: false,
    },
  ],
};

Позвольте объяснить решения, которые имеют значение:

instances: 2 вместо "max": На маленьком VPS с 1-2 ядрами "max" звучит умно, но он порождает процессы, которые конкурируют за ресурсы во время сборки. Два экземпляра дают вам перезагрузку без даунтайма, оставляя запас. На машине с 4+ ядрами — конечно, используйте "max".

exec_mode: "cluster": Это то, что включает перезагрузку без даунтайма. Без кластерного режима pm2 reload — просто красивый рестарт. С кластерным режимом PM2 перезапускает экземпляры по одному — ваше приложение никогда не уходит полностью в оффлайн.

max_memory_restart: "500M": У вашего Next.js-приложения утечка памяти? PM2 перезапустит его до того, как OOM-killer убьет ваш сервер. Это спасало меня от алертов в 2 часа ночи не раз.

kill_timeout: 5000: Дает вашему приложению 5 секунд на завершение текущих запросов до принудительного завершения PM2. Дефолт (1600мс) слишком агрессивен для приложений с подключениями к базе данных.

watch: false: Я видел, как люди оставляли watch: true в продакшене. PM2 тогда перезапускает приложение каждый раз, когда меняется файл лога. Ваше приложение входит в петлю перезапусков. Не делайте так.

Скрипт запуска#

Сделайте так, чтобы PM2 переживал перезагрузки:

bash
pm2 startup systemd
# Скопируйте и выполните команду, которую он выведет
pm2 save

Это генерирует systemd-сервис. После перезагрузки сервера ваше приложение вернется автоматически. Протестируйте это — перезагрузите сервер и проверьте. Не предполагайте.

Ротация логов#

Логи рано или поздно съедят ваш диск. Установите модуль ротации:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true

Максимум 50МБ на файл, хранить 7 ротированных файлов, сжимать старые. Без этого я видел, как /var/log заполнял 25ГБ диск за три недели на приложении со средним трафиком.

Nginx: обратный прокси, который умеет больше, чем вы думаете#

«Почему бы просто не выставить Node.js напрямую на порт 80?»

Потому что Nginx берет на себя то, на что Node.js не должен тратить ресурсы: терминацию SSL, раздачу статических файлов, gzip-сжатие, буферизацию запросов, лимиты подключений и грациозную обработку медленных клиентов. Он написан на C и создан специально для этого.

Установка#

bash
sudo apt install nginx -y

Конфигурация#

nginx
# /etc/nginx/sites-available/akousa.net
 
upstream node_app {
    server 127.0.0.1:3002;
    keepalive 64;
}
 
server {
    listen 80;
    listen [::]:80;
    server_name akousa.net www.akousa.net;
 
    # Перенаправление всего HTTP на HTTPS
    return 301 https://$host$request_uri;
}
 
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name akousa.net www.akousa.net;
 
    # SSL (управляется Certbot — эти строки добавляются автоматически)
    ssl_certificate /etc/letsencrypt/live/akousa.net/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/akousa.net/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
 
    # Заголовки безопасности
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
 
    # Gzip-сжатие
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 256;
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml
        application/wasm;
 
    # Настройки проксирования
    location / {
        proxy_pass http://node_app;
        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;
 
        # Поддержка WebSocket (если когда-нибудь понадобится)
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
 
        # Таймауты — щедрые, но не бесконечные
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
 
        # Буферизация — пусть Nginx обрабатывает медленных клиентов
        proxy_buffering on;
        proxy_buffer_size 16k;
        proxy_buffers 4 32k;
        proxy_busy_buffers_size 64k;
    }
 
    # Статические ассеты Next.js — пусть Nginx раздает их напрямую
    location /_next/static/ {
        alias /var/www/akousa.net/.next/static/;
        expires 365d;
        access_log off;
        add_header Cache-Control "public, immutable";
    }
 
    # Публичные статические файлы
    location /static/ {
        alias /var/www/akousa.net/public/static/;
        expires 30d;
        access_log off;
    }
 
    # Блокировка доступа к dot-файлам
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

Активируем:

bash
sudo ln -s /etc/nginx/sites-available/akousa.net /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Всегда выполняйте nginx -t перед перезагрузкой. Однажды я запушил битую конфигурацию и уронил сайт, потому что пропустил проверку синтаксиса. Пять символов nginx -t сэкономили бы мне тридцать минут панической отладки.

Что большинство туториалов упускает в этой конфигурации:

Блок upstream с keepalive 64: Nginx переиспользует соединения к вашему Node.js-бэкенду вместо того, чтобы открывать новое TCP-соединение для каждого запроса. Это важно под нагрузкой.

proxy_buffering on: Nginx считывает весь ответ от Node.js в память, а затем отправляет его клиенту с той скоростью, с которой клиент может принимать. Без этого медленный клиент на 3G-соединении занимает ваш Node.js-воркер.

Раздача _next/static/ напрямую: Это хешированные, неизменяемые ассеты. Пусть Nginx раздает их с диска с заголовком кеша на 365 дней. Ваши Node.js-процессы не должны тратить на это время.

SSL за пять минут#

Let's Encrypt решил проблему SSL. Если вы всё ещё платите за сертификаты в 2026 году — перестаньте.

bash
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.net

Certbot спросит вашу почту, примет ToS и автоматически модифицирует конфигурацию Nginx, добавив директивы SSL. Вот и всё.

Проверьте автообновление#

Certbot устанавливает systemd-таймер, который проверяет дважды в день и обновляет сертификаты за 30 дней до истечения:

bash
sudo systemctl list-timers | grep certbot

Проверьте, что обновление работает:

bash
sudo certbot renew --dry-run

Если dry run прошел, вы больше никогда не будете думать об SSL. Если он упал, обычно проблема в том, что порт 80 заблокирован (проверьте правила UFW) или Nginx не запущен.

Один момент, который меня поймал: если вы настроили Nginx до запуска Certbot, убедитесь, что ваш server block слушает порт 80 без HTTPS-редиректа сначала. Certbot должен достучаться до порта 80 для HTTP-01 challenge. После успешной работы Certbot тогда добавляйте редирект.

Деплой-скрипт#

Это скрипт, который запускается каждый раз, когда я пушу в продакшен. Никакой CI/CD-платформы, никаких GitHub Actions. Просто SSH и bash.

bash
#!/bin/bash
# deploy.sh — деплой с почти нулевым даунтаймом
 
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
APP_NAME="akousa"
LOG_FILE="/var/log/deploy.log"
HEALTH_URL="http://localhost:3002"
MAX_RETRIES=10
RETRY_INTERVAL=3
 
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
 
log "=== Deploy started ==="
 
cd "$APP_DIR"
 
# Стягиваем последний код
log "Pulling latest changes..."
git pull origin main 2>&1 | tee -a "$LOG_FILE"
 
# Устанавливаем зависимости
log "Installing dependencies..."
npm install --legacy-peer-deps 2>&1 | tee -a "$LOG_FILE"
 
# Сборка
log "Building application..."
rm -rf .next
npm run build 2>&1 | tee -a "$LOG_FILE"
 
if [ $? -ne 0 ]; then
    log "ERROR: Build failed. Aborting deploy."
    exit 1
fi
 
# Перезагрузка PM2 (без даунтайма в кластерном режиме)
log "Reloading PM2..."
pm2 reload "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
pm2 save 2>&1 | tee -a "$LOG_FILE"
 
# Хелсчек с повторами
log "Running health check..."
for i in $(seq 1 $MAX_RETRIES); do
    HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo "000")
    if [ "$HTTP_CODE" = "200" ]; then
        log "Health check passed (HTTP $HTTP_CODE)"
        log "=== Deploy completed successfully ==="
        exit 0
    fi
    log "Health check attempt $i/$MAX_RETRIES (HTTP $HTTP_CODE). Retrying in ${RETRY_INTERVAL}s..."
    sleep $RETRY_INTERVAL
done
 
log "ERROR: Health check failed after $MAX_RETRIES attempts"
log "Rolling back to previous PM2 state..."
pm2 restart "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
exit 1

Делаем исполняемым:

bash
chmod +x deploy.sh

Деплой с локальной машины:

bash
ssh root@your-server-ip "bash /var/www/akousa.net/deploy.sh"

Ключевые решения в этом скрипте:

set -euo pipefail: Скрипт завершается немедленно при любой ошибке. Без этого неудачный npm install молча продолжается до этапа сборки, и вы получаете криптичную ошибку, на отладку которой уходит 20 минут.

rm -rf .next перед сборкой: Next.js имеет кеш сборки, который иногда выдает устаревший вывод. Меня это однажды укусило — страница показывала старый контент, несмотря на обновленный исходный код. Удаление директории сборки добавляет может быть 15 секунд к сборке, но гарантирует свежий вывод.

pm2 reload вместо pm2 restart: Это и есть часть с нулевым даунтаймом. В кластерном режиме reload выполняет скользящий перезапуск — поднимает новые экземпляры с обновленным кодом, ждет их готовности, затем грациозно останавливает старые. Ни в один момент времени не работает ноль экземпляров.

Хелсчек с повторами: Next.js нужно несколько секунд на прогрев после перезапуска. Скрипт ждет до 30 секунд (10 повторов x 3 секунды), проверяя, отвечает ли приложение HTTP 200. Если нет — что-то не так, и вам нужно знать об этом немедленно, а не узнавать от пользователя.

Откат при ошибке: Если хелсчек не прошел после всех повторов, скрипт перезапускает PM2 (который загружает последнее сохраненное состояние). Это не идеальный откат, но лучше, чем оставить сервер в нерабочем состоянии.

Когда всё ломается в 2 часа ночи#

Вот что я реально дебажил на этой самой настройке:

«Сайт лежит»#

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

bash
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.log

В девяти случаях из десяти pm2 logs сразу говорит, что произошло. Отсутствующая переменная окружения, неудачное подключение к базе данных или необработанный promise rejection.

«Память продолжает расти»#

bash
pm2 monit

Это дает вам живой дашборд CPU и памяти по каждому процессу. Если память стабильно растет без выравнивания, у вас утечка. Настройка max_memory_restart в конфигурации экосистемы — ваша страховочная сетка: PM2 перезапустит процесс до того, как он уронит сервер.

Для более глубокого расследования:

bash
pm2 describe akousa

Это показывает аптайм, количество рестартов и снимки памяти. Если вы видите 47 перезапусков за последние 24 часа — это подсказка.

«SSL-сертификат истек»#

bash
sudo certbot certificates

Показывает все сертификаты с датами истечения. Если автообновление не сработало:

bash
sudo certbot renew --force-renewal
sudo systemctl reload nginx

«Место на диске закончилось»#

bash
df -h
du -sh /var/log/*
pm2 flush

pm2 flush очищает все файлы логов PM2 немедленно. Если вы не настроили ротацию логов (я же предупреждал), вот тут вы почувствуете боль.

Команда, которую я запускаю каждое утро#

bash
ssh deploy@akousa.net "pm2 status && df -h / && uptime"

Три вещи в одной строке: работают ли мои процессы, в порядке ли мой диск, не перегружен ли сервер. Занимает две секунды. Ловит проблемы до того, как их заметят пользователи.

Чего большинство руководств не расскажет#

Этап сборки — ваша главная уязвимость. На VPS с 1ГБ RAM npm run build для Next.js-приложения может потребить 800МБ+ памяти. Если PM2 запускает ваше приложение в двух экземплярах во время сборки, вы получите OOM. Решения: используйте swap-файл (минимум 2ГБ) или останавливайте приложение на время сборки и мириться с несколькими секундами даунтайма. Я использую swap.

bash
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

--legacy-peer-deps в команде установки — это code smell, а не решение. Я использую его, потому что некоторые пакеты в моем дереве зависимостей не обновили диапазоны peer-зависимостей. Раз в несколько месяцев я пробую убрать его. Когда-нибудь сработает. А пока — я шиплю.

Протестируйте свой деплой-скрипт с нуля. Склонируйте репозиторий на свежий сервер и выполните каждый шаг вручную. Количество проблем «работает на моей машине», скрытых в деплой-скриптах — это позор. Я нашел три проблемы в своем, когда сделал это — отсутствующие глобальные пакеты, неправильные права файлов и путь, который существовал только из-за предыдущей ручной настройки.

Добавьте IP вашего сервера в SSH-конфиг. Хватит набирать IP-адреса:

bash
# ~/.ssh/config
Host akousa
    HostName 69.62.66.94
    User deploy
    IdentityFile ~/.ssh/id_ed25519

Теперь ssh akousa — это всё, что нужно. Мелочи накапливаются.

Полный чеклист#

Прежде чем считать дело сделанным:

  • Пользователь без root с доступом sudo
  • Только аутентификация по SSH-ключам, пароли отключены
  • UFW включен, открыты только необходимые порты
  • Fail2Ban защищает SSH
  • Автоматические обновления безопасности включены
  • Node.js установлен через NVM
  • PM2 запускает приложение в кластерном режиме
  • Скрипт запуска PM2 настроен (переживает перезагрузку)
  • Ротация логов PM2 установлена
  • Обратный прокси Nginx с правильными заголовками
  • SSL через Let's Encrypt с автообновлением
  • Деплой-скрипт с хелсчеками
  • Swap-файл настроен (запас для сборки)
  • Протестировано: перезагрузить сервер и проверить, что всё вернулось

Последний пункт — тот, который все пропускают. Не будьте таким человеком. Перезагрузите сервер, подождите 60 секунд и проверьте, работает ли ваше приложение. Если нет — ваши скрипты запуска настроены неправильно, и вы узнаете об этом в самый неподходящий момент.

Это «энтерпрайз-уровень»?#

Нет. И в этом суть.

Эта настройка надежно обслуживает этот блог менее чем за $10 в месяц. Деплоится за 30 секунд одной командой. Я понимаю каждую её часть. Когда что-то ломается, я точно знаю, куда смотреть.

Мог ли я использовать Docker? Конечно. Мог ли я использовать Kubernetes? Технически. Мог ли я настроить полный CI/CD-пайплайн со staging-окружениями и канареечными деплоями? Безусловно.

Но я усвоил, что лучшая инфраструктура — та, которую вы действительно понимаете, можете дебажить в 2 часа ночи и которая не стоит больше, чем зарабатывает проект. Для личного сайта, MVP SaaS-продукта или маленького стартапа — это именно такая настройка.

Шипьте сначала. Масштабируйтесь, когда понадобится. И всегда, всегда тестируйте деплой-скрипт на свежем сервере.