Точная настройка 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-порт в течение минут после создания.
adduser deploy
usermod -aG sudo deployНа вашей локальной машине:
ssh-copy-id deploy@your-server-ipЗатем полностью отключите аутентификацию по паролю:
sudo nano /etc/ssh/sshd_configPasswordAuthentication no
PermitRootLogin nosudo systemctl restart sshdЕсли вы пропустите это, то увидите тысячи неудачных попыток входа в auth-логах в течение нескольких дней. Это не паранойя — это обычный вторник в публичном интернете.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableВот и всё. Четыре правила. Проходит только SSH и веб-трафик.
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Ваш сервер теперь будет автоматически устанавливать патчи безопасности. Одной вещью меньше можно забыть.
Я вижу это в каждом туториале: 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 — это разница между «задеплоено» и «готово к продакшену». Он обеспечивает управление процессами, кластеризацию, ротацию логов, автоперезапуск при падениях и скрипты запуска. Бесплатно.
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ГБ диск за три недели на приложении со средним трафиком.
«Почему бы просто не выставить 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-процессы не должны тратить на это время.
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 (который загружает последнее сохраненное состояние). Это не идеальный откат, но лучше, чем оставить сервер в нерабочем состоянии.
Вот что я реально дебажил на этой самой настройке:
Первые команды:
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 часа — это подсказка.
sudo certbot certificatesПоказывает все сертификаты с датами истечения. Если автообновление не сработало:
sudo certbot renew --force-renewal
sudo systemctl reload nginxdf -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 — это всё, что нужно. Мелочи накапливаются.
Прежде чем считать дело сделанным:
Последний пункт — тот, который все пропускают. Не будьте таким человеком. Перезагрузите сервер, подождите 60 секунд и проверьте, работает ли ваше приложение. Если нет — ваши скрипты запуска настроены неправильно, и вы узнаете об этом в самый неподходящий момент.
Нет. И в этом суть.
Эта настройка надежно обслуживает этот блог менее чем за $10 в месяц. Деплоится за 30 секунд одной командой. Я понимаю каждую её часть. Когда что-то ломается, я точно знаю, куда смотреть.
Мог ли я использовать Docker? Конечно. Мог ли я использовать Kubernetes? Технически. Мог ли я настроить полный CI/CD-пайплайн со staging-окружениями и канареечными деплоями? Безусловно.
Но я усвоил, что лучшая инфраструктура — та, которую вы действительно понимаете, можете дебажить в 2 часа ночи и которая не стоит больше, чем зарабатывает проект. Для личного сайта, MVP SaaS-продукта или маленького стартапа — это именно такая настройка.
Шипьте сначала. Масштабируйтесь, когда понадобится. И всегда, всегда тестируйте деплой-скрипт на свежем сервере.