Перейти до вмісту
·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" в інтернеті зупиняється на щасливому шляху. Вам показують, як встановити 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

Якщо ви пропустите це, ви побачите тисячі невдалих спроб входу у ваших логах автентифікації за кілька днів. Це не параноя — це звичайний вівторок у публічному інтернеті.

Фаєрвол з 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: false,
    },
  ],
};

Дозвольте пояснити рішення, які мають значення:

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

exec_mode: "cluster": Саме це дає можливість перезавантаження без простою. Без кластерного режиму pm2 reload — просто гарний рестарт. З кластерним режимом PM2 перезапускає інстанси по одному — ваш додаток ніколи повністю не йде в офлайн.

max_memory_restart: "500M": У вашому додатку Next.js витік пам'яті? PM2 перезапустить його до того, як він вб'є ваш сервер через OOM. Це рятувало мене від тривог о 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;
    }
 
    # Блокування доступу до прихованих файлів
    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 worker.

Роздача _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

Якщо тестовий запуск пройшов — ви більше ніколи не думатимете про SSL. Якщо він зазнав невдачі, зазвичай причина в тому, що порт 80 заблокований (перевірте правила UFW) або Nginx не запущений.

Одна річ, що мене зловила: якщо ви налаштували Nginx до запуску Certbot, переконайтеся, що ваш серверний блок слухає порт 80 без HTTPS-перенаправлення спочатку. Certbot потрібен доступ до порту 80 для HTTP-01 челенджу. Після успішного запуску 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 "=== Деплой розпочато ==="
 
cd "$APP_DIR"
 
# Отримання останнього коду
log "Отримання останніх змін..."
git pull origin main 2>&1 | tee -a "$LOG_FILE"
 
# Встановлення залежностей
log "Встановлення залежностей..."
npm install --legacy-peer-deps 2>&1 | tee -a "$LOG_FILE"
 
# Збірка
log "Збірка додатку..."
rm -rf .next
npm run build 2>&1 | tee -a "$LOG_FILE"
 
if [ $? -ne 0 ]; then
    log "ПОМИЛКА: Збірка не вдалася. Деплой перервано."
    exit 1
fi
 
# Перезавантаження PM2 (без простою в кластерному режимі)
log "Перезавантаження PM2..."
pm2 reload "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
pm2 save 2>&1 | tee -a "$LOG_FILE"
 
# Перевірка здоров'я з повторними спробами
log "Запуск перевірки здоров'я..."
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 "Перевірка здоров'я пройдена (HTTP $HTTP_CODE)"
        log "=== Деплой завершено успішно ==="
        exit 0
    fi
    log "Спроба перевірки здоров'я $i/$MAX_RETRIES (HTTP $HTTP_CODE). Повтор через ${RETRY_INTERVAL}с..."
    sleep $RETRY_INTERVAL
done
 
log "ПОМИЛКА: Перевірка здоров'я не вдалася після $MAX_RETRIES спроб"
log "Відкат до попереднього стану PM2..."
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 одразу показує, що сталося. Відсутня змінна середовища, невдале підключення до бази даних або необроблений відхилений проміс.

"Пам'ять постійно зростає"#

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 у вашій команді install — це кодовий запах, а не рішення. Я використовую його, бо деякі пакети в моєму дереві залежностей не оновили діапазони 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 пайплайн зі стейджинг-середовищами та канарковими деплоями? Безумовно.

Але я навчився, що найкраща інфраструктура — це та, яку ви реально розумієте, можете дебажити о 2 ночі і яка не коштує більше, ніж заробляє проєкт. Для особистого сайту, SaaS MVP або невеликого стартапу — це саме те налаштування.

Спершу шіпніть. Масштабуйте, коли потрібно. І завжди, завжди тестуйте скрипт деплою на свіжому сервері.