Настройка VPS, которая реально работает: Node.js, PM2, Nginx и деплой без даунтайма
Точная настройка VPS для деплоя, которую я использую в продакшене — защита Ubuntu, кластерный режим PM2, обратный прокси Nginx, SSL и деплой-скрипт, который ни разу не подвел. Никакой теории, только то, что работает.
Этот блог работает на 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-привилегий#
adduser deploy
usermod -aG sudo deployНастройте аутентификацию по SSH-ключам#
На вашей локальной машине:
ssh-copy-id deploy@your-server-ipЗатем полностью отключите аутентификацию по паролю:
sudo nano /etc/ssh/sshd_configPasswordAuthentication no
PermitRootLogin nosudo systemctl restart sshdЕсли вы пропустите это, то увидите тысячи неудачных попыток входа в auth-логах в течение нескольких дней. Это не паранойя — это обычный вторник в публичном интернете.
Файрвол с UFW#
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableВот и всё. Четыре правила. Проходит только SSH и веб-трафик.
Fail2Ban#
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.localОтредактируйте /etc/fail2ban/jail.local:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600sudo systemctl enable fail2ban
sudo systemctl start fail2banТри неудачные попытки SSH — и вы забанены на час. Я наблюдал, как Fail2Ban блокировал сотни IP за один день. Оно работает.
Автоматические обновления безопасности#
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 решает это:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*Теперь проверьте:
node -v # v22.x.x или текущая LTS-версия
npm -vНеочевидный совет: когда вы устанавливаете глобальные пакеты через NVM (например, PM2), они привязаны к конкретной версии Node. Если вы переключите версию через nvm use, ваши глобальные пакеты исчезнут. Установите дефолт и придерживайтесь его на сервере:
nvm alias default 22Меня это укусило ровно один раз. Одного раза хватило.
PM2: менеджер процессов, который отрабатывает на все сто#
PM2 — это разница между «задеплоено» и «готово к продакшену». Он обеспечивает управление процессами, кластеризацию, ротацию логов, автоперезапуск при падениях и скрипты запуска. Бесплатно.
Установка и настройка#
npm install -g pm2Конфигурация экосистемы#
Не запускайте приложения через CLI-флаги. Используйте файл ecosystem.config.js. Он версионируется, воспроизводим и самодокументирован.
// 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 переживал перезагрузки:
pm2 startup systemd
# Скопируйте и выполните команду, которую он выведет
pm2 saveЭто генерирует systemd-сервис. После перезагрузки сервера ваше приложение вернется автоматически. Протестируйте это — перезагрузите сервер и проверьте. Не предполагайте.
Ротация логов#
Логи рано или поздно съедят ваш диск. Установите модуль ротации:
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 и создан специально для этого.
Установка#
sudo apt install nginx -yКонфигурация#
# /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;
}
}Активируем:
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 году — перестаньте.
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.netCertbot спросит вашу почту, примет ToS и автоматически модифицирует конфигурацию Nginx, добавив директивы SSL. Вот и всё.
Проверьте автообновление#
Certbot устанавливает systemd-таймер, который проверяет дважды в день и обновляет сертификаты за 30 дней до истечения:
sudo systemctl list-timers | grep certbotПроверьте, что обновление работает:
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.
#!/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Делаем исполняемым:
chmod +x deploy.shДеплой с локальной машины:
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 часа ночи#
Вот что я реально дебажил на этой самой настройке:
«Сайт лежит»#
Первые команды:
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.logВ девяти случаях из десяти pm2 logs сразу говорит, что произошло. Отсутствующая переменная окружения, неудачное подключение к базе данных или необработанный promise rejection.
«Память продолжает расти»#
pm2 monitЭто дает вам живой дашборд CPU и памяти по каждому процессу. Если память стабильно растет без выравнивания, у вас утечка. Настройка max_memory_restart в конфигурации экосистемы — ваша страховочная сетка: PM2 перезапустит процесс до того, как он уронит сервер.
Для более глубокого расследования:
pm2 describe akousaЭто показывает аптайм, количество рестартов и снимки памяти. Если вы видите 47 перезапусков за последние 24 часа — это подсказка.
«SSL-сертификат истек»#
sudo certbot certificatesПоказывает все сертификаты с датами истечения. Если автообновление не сработало:
sudo certbot renew --force-renewal
sudo systemctl reload nginx«Место на диске закончилось»#
df -h
du -sh /var/log/*
pm2 flushpm2 flush очищает все файлы логов PM2 немедленно. Если вы не настроили ротацию логов (я же предупреждал), вот тут вы почувствуете боль.
Команда, которую я запускаю каждое утро#
ssh deploy@akousa.net "pm2 status && df -h / && uptime"Три вещи в одной строке: работают ли мои процессы, в порядке ли мой диск, не перегружен ли сервер. Занимает две секунды. Ловит проблемы до того, как их заметят пользователи.
Чего большинство руководств не расскажет#
Этап сборки — ваша главная уязвимость. На VPS с 1ГБ RAM npm run build для Next.js-приложения может потребить 800МБ+ памяти. Если PM2 запускает ваше приложение в двух экземплярах во время сборки, вы получите OOM. Решения: используйте swap-файл (минимум 2ГБ) или останавливайте приложение на время сборки и мириться с несколькими секундами даунтайма. Я использую swap.
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-адреса:
# ~/.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-продукта или маленького стартапа — это именно такая настройка.
Шипьте сначала. Масштабируйтесь, когда понадобится. И всегда, всегда тестируйте деплой-скрипт на свежем сервере.