Развёртывание Node-приложения на VPS через systemd
В моём списке стандартных задач «поднять Node-сервис на VPS под systemd» — одна из самых часто повторяемых. Раз в пару недель кто-нибудь спрашивает «а как ты деплоишь без докера». Расскажу свой шаблон. Цель — получить долгоживущий процесс с автозапуском, корректным выключением, логами в journald и health-check.
Контекст: Ubuntu 24.04 на VPS, Node 20 через nvm или из NodeSource, приложение на TypeScript, собранное в dist/. Никакого pm2 — systemd сам прекрасно справляется и интегрируется с journald.
Структура каталогов на сервере
Я держу простую и предсказуемую раскладку. Это не догма, но за 4 года работы ни разу не пожалел:
/opt/myapp/ # код приложения
current/ # симлинк на текущий релиз
releases/
2026-05-23-1234abc/ # каждый релиз — отдельная папка
shared/
.env # переменные окружения
uploads/ # пользовательские файлы
logs/ # на случай, если нужны файлы помимо journaldПри деплое я кладу новый релиз в releases/, переключаю симлинк current/ и перезапускаю service. Откат — переключение симлинка на предыдущий релиз. Никаких git pull в проде, ничего, что меняет код прямо в работающей директории.
Системный пользователь
Сервис должен жить от не-root пользователя. У меня всегда:
sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin app
sudo chown -R app:app /opt/myappБез shell, без homedir в реальном смысле слова, только для запуска процесса. Если что-то взломают — у атакующего нет интерактивного входа.
systemd unit
Файл /etc/systemd/system/myapp.service:
[Unit]
Description=MyApp Node service
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=app
Group=app
WorkingDirectory=/opt/myapp/current
ExecStart=/usr/bin/node /opt/myapp/current/dist/server.js
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=3
KillSignal=SIGTERM
TimeoutStopSec=30
Environment=NODE_ENV=production
Environment=NODE_OPTIONS=--enable-source-maps
EnvironmentFile=/opt/myapp/shared/.env
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
# Безопасность
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/myapp/shared/logs /opt/myapp/shared/uploads
[Install]
WantedBy=multi-user.targetЧто важно объяснить:
- Type=simple. systemd считает процесс запущенным, как только запустил ExecStart. Для Node-приложения этого достаточно, потому что веб-сервер не форкается.
- Restart=on-failure + StartLimit. Если приложение падает — перезапускаем, но не более трёх раз в минуту. Иначе логи и CPU забьются циклом «упало → стартанули → упало».
- KillSignal=SIGTERM + TimeoutStopSec=30. 30 секунд на graceful shutdown. Если не закрылись — пристрелит SIGKILL. В коде должен быть обработчик SIGTERM, который доделывает HTTP-запросы.
- EnvironmentFile. Все секреты в
.envс правами 600 для пользователя app. systemd подхватит при запуске, вpsиenvironих не будет видно посторонним процессам, если включитьProtectKernelTunablesи компанию. - NoNewPrivileges, PrivateTmp, ProtectSystem. Включаешь, проверяешь. На моём опыте не ломает почти ничего, кроме случаев, когда приложение пишет в произвольные места ФС. Если пишет — добавляй пути в
ReadWritePaths.
Graceful shutdown в коде
Без него systemd честно подождёт TimeoutStopSec и пристрелит. Минимум:
const server = app.listen(port, () => console.log('listening', port));
function shutdown(signal: string) {
console.log(`received ${signal}, shutting down`);
server.close((err) => {
if (err) {
console.error('close error', err);
process.exit(1);
}
process.exit(0);
});
// если за 25 сек не закрылись — выходим принудительно
setTimeout(() => process.exit(1), 25_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));Если в приложении есть BullMQ-воркеры или подключения к БД — добавляй их остановку перед process.exit. Иначе пара тысяч активных коннектов к Postgres могут зависнуть в TIME_WAIT.
Активация и проверка
sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
sudo systemctl status myapp.service
# логи
sudo journalctl -u myapp.service -f --since '1h ago'journalctl -u myapp -f — мой основной способ смотреть логи. Никаких дополнительных файлов: всё проиндексировано, понятен since, grep работает.
Несколько процессов одного приложения
Иногда хочется запустить N инстансов и балансировать на nginx. Можно через NODE_CLUSTER (cluster API Node), но я предпочитаю template-юнит с инстансами:
# /etc/systemd/system/myapp@.service
[Unit]
Description=MyApp instance %i
...
[Service]
Environment=PORT=300%i
ExecStart=/usr/bin/node /opt/myapp/current/dist/server.js
# остальное — как в myapp.service
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now myapp@1.service
sudo systemctl enable --now myapp@2.service
sudo systemctl enable --now myapp@3.serviceПолучаем три инстанса на портах 3001, 3002, 3003. nginx балансирует между ними upstream-ом. Удобно, что каждый инстанс перезапускается отдельно.
nginx как reverse proxy
Тут шаблон уже совсем стандартный, привожу для полноты:
upstream myapp {
server 127.0.0.1:3001;
server 127.0.0.1:3002;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name app.example.ru;
ssl_certificate /etc/letsencrypt/live/app.example.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.ru/privkey.pem;
location / {
proxy_pass http://myapp;
proxy_http_version 1.1;
proxy_set_header Connection '';
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;
}
}В Node не забудь app.set('trust proxy', 1), чтобы req.ip и req.protocol работали правильно за nginx.
Деплой-скрипт
На самом простом проекте у меня обычный bash, который запускается из CI:
#!/bin/bash
set -e
COMMIT=$(git rev-parse --short HEAD)
DATE=$(date +%F-%H%M)
RELEASE="${DATE}-${COMMIT}"
rsync -az --delete --exclude=node_modules --exclude=.git ./ app@srv:/opt/myapp/releases/${RELEASE}/
ssh app@srv bash <<EOF
set -e
cd /opt/myapp/releases/${RELEASE}
pnpm install --frozen-lockfile --prod=false
pnpm build
pnpm prune --prod
ln -sfn /opt/myapp/releases/${RELEASE} /opt/myapp/current_new
mv -Tf /opt/myapp/current_new /opt/myapp/current
sudo systemctl restart myapp.service
EOF
echo "deployed ${RELEASE}"Атомарный mv важен: если переключение симлинка прервётся, не будет полупустого состояния. Откат — то же самое, но указываешь старый RELEASE.
Health check
В Node-приложении endpoint /healthz, который проверяет соединение с БД и возвращает 200. nginx может опрашивать его, но проще делать это извне через мониторинг. У меня обычно UptimeRobot или собственный сервис, который пингует раз в минуту и алертит, если сервис не отвечает.
Логи и ротация
journald делает ротацию сам, размер ограничивается /etc/systemd/journald.conf:
SystemMaxUse=2G
SystemMaxFileSize=200M
MaxRetentionSec=14dayНа больших нагрузках я держу 2 ГБ — этого хватает, чтобы покопаться в инцидентах. Если хочется отдельный файл (например, для отправки в внешний agent) — пишу в stdout JSON, и vector/fluent-bit читает journald и пересылает в нужное место.
Что я считаю обязательным
- Не root-пользователь.
- Restart с лимитом, чтобы не залупить логи.
- SIGTERM-обработчик в Node.
- EnvironmentFile с правами 600.
- journald, не файлы.
- Атомарный деплой через симлинк.
- nginx + Let's Encrypt поверх, ничего не отдаём наружу напрямую.
Это базовая обвязка, которая работает у меня на десятках сервисов. Без Docker, без PM2, без оркестратора. Иногда docker всё-таки нужен — но только когда есть реальные причины: специфические зависимости, гетерогенный стек, оркестрация. Для одного-двух Node-приложений systemd закрывает вопрос полностью.