CI/CD для Node.js‑проекта на VDS — это не обязательно большой оркестратор и десятки зависимостей. Для большинства веб‑приложений достаточно связки GitHub Actions + SSH + rsync + systemd, чтобы получить быстрые поставки, повторяемые релизы и контролируемые откаты. Ниже — практический конвейер: подготовим сервер, организуем структуру релизов, настроим systemd‑сервис и напишем workflow, который собирает и выкатывает код по SSH. Параллельно обратим внимание на безопасность, логику отката и нюансы стабильности продакшна.
Задачи, которые закроет наш конвейер
Мы построим минималистичный и при этом надежный CI/CD вокруг GitHub Actions. Он будет:
- Собирать проект и прогонять проверки при push в основную ветку и по ручному запуску.
- По SSH доставлять релиз на VDS в новый каталог релиза, без перезаписи текущего.
- На сервере ставить production‑зависимости, прогонять миграции (если есть), переключать симлинк
currentна новый релиз и перезапускать systemd‑сервис без простоя. - Хранить историю релизов, чтобы мгновенно сделать rollback, если что‑то пошло не так.
Почему не Docker? Контейнеризация — сильный путь, но не обязателен. На одиночных VDS проще и прозрачнее управлять процессом через rsync и systemd: меньше слоев, быстрее релизы.
Подготовка VDS: пользователи, каталоги, права
Создадим отдельного пользователя для деплоя и каталоги под релизы. Это разделит ответственность, уберет лишние права и упростит управление файлами. Убедитесь, что Node.js установлен на сервере (например, Node 20+), и настроен фаервол.
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /var/www/myapp/releases
sudo mkdir -p /var/www/myapp/shared/logs
sudo mkdir -p /var/www/myapp/shared/tmp
sudo chown -R deploy:deploy /var/www/myapp
В /var/www/myapp держим схему:
releases/yyyyMMddHHmmss— каталог каждого релиза;shared/— общие данные вне релиза (логи, загрузки, кеш);current— симлинк на актуальный релиз.
Скопируйте публичный ключ CI в ~deploy/.ssh/authorized_keys и проверьте доступ по SSH. Рекомендую отдельный ключ только для деплоя и ограничение по IP в фаерволе. Если только планируете инфраструктуру — подберите надёжный VDS с запасом CPU/RAM и быстрым диском.
Слежение за известными хостами: добавьте отпечаток сервера в
known_hostsв секрете репозитория и передавайте его раннеру, чтобы rsync/ssh не отключали проверку ключа.
systemd‑сервис для Node.js
Поднимем приложение как обычный unit. Это даёт автозапуск, рестарты при сбоях и удобные перезагрузки. Секреты и переменные окружения держим в .env вне релизов.
sudo bash -c 'cat > /etc/systemd/system/myapp.service' << 'EOF'
[Unit]
Description=MyApp Node.js Service
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp/current
EnvironmentFile=/var/www/myapp/shared/myapp.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
KillSignal=SIGTERM
TimeoutStopSec=20
# Плавная перезагрузка: systemctl reload myapp
ExecReload=/bin/kill -s SIGTERM $MAINPID
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable myapp
В /var/www/myapp/shared/myapp.env разместите чувствительные переменные (ключи, DSN, токены). Они не попадут в репозиторий и будут общими для всех релизов.
Для корректного завершения по SIGTERM добавьте в Node.js обработчик:
// server.js (фрагмент)
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully...')
server.close(() => {
console.log('HTTP server closed')
process.exit(0)
})
setTimeout(() => process.exit(1), 10000)
})
Nginx как обратный прокси (по желанию)
Часто Node‑приложение слушает локальный порт, а наружу торчит Nginx. Это даёт TLS‑терминацию, сжатие, кеш статики и грамотные таймауты. Минимальный конфиг:
server {
listen 80;
server_name example.com;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:3000;
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;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
location /assets/ {
root /var/www/myapp/current/public;
expires 7d;
add_header Cache-Control "public";
}
}
После переключения симлинка current статика будет подтягиваться из нового релиза без ребута Nginx. Для боевого трафика включите HTTPS и оформите SSL‑сертификаты.

Сценарий деплоя на сервере
Чтобы git push приводил к безопасной выкладке, на сервере используем небольшой скрипт финализации. Он установит production‑зависимости, привяжет shared‑ресурсы, переключит симлинк и перезапустит сервис.
sudo bash -c 'cat > /usr/local/bin/deploy_myapp.sh' << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/var/www/myapp
RELEASES_DIR=$APP_DIR/releases
SHARED_DIR=$APP_DIR/shared
CURRENT_LINK=$APP_DIR/current
RELEASE_NAME=${1:-}
if [ -z "$RELEASE_NAME" ]; then
echo "Usage: deploy_myapp.sh <release_name>" >&2
exit 1
fi
RELEASE_PATH=$RELEASES_DIR/$RELEASE_NAME
# Привязываем shared каталоги
mkdir -p $RELEASE_PATH/logs
rm -rf $RELEASE_PATH/logs
ln -s $SHARED_DIR/logs $RELEASE_PATH/logs
mkdir -p $RELEASE_PATH/tmp
rm -rf $RELEASE_PATH/tmp
ln -s $SHARED_DIR/tmp $RELEASE_PATH/tmp
# Установка prod-зависимостей
cd $RELEASE_PATH
if [ -f package-lock.json ]; then
npm ci --omit=dev
else
npm install --production
fi
# Сборка (если нужна)
if [ -f package.json ] && jq -e '.scripts.build' package.json >/dev/null 2>&1; then
npm run build
fi
# Переключаем симлинк атомарно
ln -sfn $RELEASE_PATH $CURRENT_LINK
# Перезапуск
sudo systemctl reload myapp || sudo systemctl restart myapp
# Чистка старых релизов (оставим 5)
cd $RELEASES_DIR
ls -1tr | head -n -5 | xargs -r rm -rf
echo "Deployed $RELEASE_NAME"
EOF
sudo chmod +x /usr/local/bin/deploy_myapp.sh
Скрипт предполагает, что содержимое релиза уже в releases/<timestamp>. Он аккуратно переключает симлинк, вызывает reload (или restart), и удаляет старые релизы, оставляя пять последних — для быстрого rollback.
SSH‑деплой через rsync
Сборку и доставку делаем из GitHub Actions: каждый пуш в main запускает джобу, которая прогоняет тесты, собирает артефакт, а затем rsync’ом закидывает его в новый каталог релиза на VDS. После загрузки — удалённый вызов скрипта финализации.
name: ci-cd
on:
push:
branches: [ "main" ]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install deps
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: |
if jq -e '.scripts.build' package.json >/dev/null 2>&1; then
npm run build
fi
- name: Prepare release name
id: rel
run: echo "name=$(date +%Y%m%d%H%M%S)" >> $GITHUB_OUTPUT
- name: Add server to known_hosts
run: |
mkdir -p ~/.ssh
echo "$KNOWN_HOSTS" >> ~/.ssh/known_hosts
env:
KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS_MYAPP }}
- name: Push files via rsync
env:
SSH_KEY: ${{ secrets.SSH_KEY_MYAPP }}
RELEASE: ${{ steps.rel.outputs.name }}
run: |
echo "$SSH_KEY" > key
chmod 600 key
rsync -az --delete --exclude=node_modules --exclude=.git -e "ssh -i key" . deploy@vds.example:/var/www/myapp/releases/$RELEASE
- name: Finalize on server
env:
SSH_KEY: ${{ secrets.SSH_KEY_MYAPP }}
RELEASE: ${{ steps.rel.outputs.name }}
run: |
ssh -i key deploy@vds.example "/usr/local/bin/deploy_myapp.sh $RELEASE"
Ключевые моменты:
known_hostsхраните в секрете, чтобы не отключать проверку хоста.rsync -az --delete --exclude=node_modulesгрузит чистый код; прод‑зависимости ставим уже на сервере (выше совместимость).- Имя релиза — timestamp, удобно для сортировки и откатов.
Если нужна расширенная схема с сохранением бэкапов и сухим прогоном, посмотрите материал «rsync‑деплой и бэкапы по SSH».
Rollback: быстрый откат на предыдущий релиз
Храним несколько релизов в releases/: откат — это переключение симлинка current на предыдущую версию и перезагрузка сервиса. Добавим простой скрипт:
sudo bash -c 'cat > /usr/local/bin/rollback_myapp.sh' << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/var/www/myapp
RELEASES_DIR=$APP_DIR/releases
CURRENT_LINK=$APP_DIR/current
cd $RELEASES_DIR
CURRENT=$(readlink -f $CURRENT_LINK | xargs basename)
PREV=$(ls -1tr | grep -v "$CURRENT" | tail -n 1)
if [ -z "$PREV" ]; then
echo "No previous release found" >&2
exit 1
fi
ln -sfn $RELEASES_DIR/$PREV $CURRENT_LINK
sudo systemctl reload myapp || sudo systemctl restart myapp
echo "Rolled back to $PREV"
EOF
sudo chmod +x /usr/local/bin/rollback_myapp.sh
Откат выполняется одной командой из консоли или из ручной джобы workflow_dispatch в CI.

Практические нюансы и устойчивость
- Грациозное завершение. Обработчики SIGTERM и таймауты позволяют перезапускаться без потери запросов.
- Идемпотентность. Шаги деплоя должны быть безопасны при повторном запуске: помогают
ln -sfnиsystemctl reload. - Очистка релизов. Держите 5–10 последних, остальное удаляйте автоматически.
- Таймауты прокси. В Nginx настройте
proxy_read_timeoutи лимитclient_max_body_sizeпод вашу нагрузку. - Логи. Пишите в
shared/logs, чтобы они переживали релизы и их было чем анализировать после отката. - Зависимости.
npm ci --omit=devдаёт воспроизводимые сборки и меньше «сюрпризов».
Секреты и переменные окружения
- В CI: закрытые ключи SSH и
known_hosts. Храните в секретах и не логируйте. - На сервере:
/var/www/myapp/shared/myapp.env. Не трогайте из CI, чтобы не смешивать окружения.
В EnvironmentFile держите редко меняемую конфигурацию: DSN базы, ключи API, параметры интеграций. При изменении — systemctl daemon-reload и systemctl restart myapp.
Zero‑downtime и миграции БД
Если есть миграции, следуйте принципу совместимости вперёд/назад: сначала добавляйте поля и индексы, выдерживайте время, затем вычищайте старое. Запускайте миграции до переключения симлинка, чтобы новая схема была совместима и со старым, и с новым кодом. Для общих подходов к безостановочным изменениям инфраструктуры пригодится материал «миграция без простоя».
Диагностика и автоматический откат
Простой критерий успеха релиза — healthcheck после переключения симлинка. В конце deploy_myapp.sh добавьте проверку HTTP‑эндпоинта и откат при неуспехе:
- После
reload/restartподождите несколько секунд. - Проверьте
/health. Если код ответа не 2xx — вызовитеrollback_myapp.sh.
Безопасность SSH‑деплоя
- Раздельные ключи для деплоя и админ‑доступа. Ключ деплоя — только в CI и у пользователя
deploy. - Жёсткая проверка
known_hosts. Не отключайте её, храните отпечатки в секретах. - При желании ограничьте команды в
authorized_keys, разрешив только rsync и скрипты деплоя. - Следите за правами на
/var/www/myapp: запись — только владельцу.
Что дальше улучшить
- Кэширование
node_modulesна сервере между релизами для ускоренияnpm ci. - Systemd watchdog и healthcheck‑скрипт с оповещениями.
- Ротация логов через
logrotateдляshared/logs. - Нотификации в мессенджеры по итогам релиза (коммит, автор, время).
- Разделение стейджинга и продакшна отдельными джобами и ключами.
Главная мысль: не усложняйте без необходимости. Для одиночного VDS в большинстве проектов этого хватает на 90% задач: предсказуемые релизы, быстрые откаты и минимум ручных действий. Когда вырастете до нескольких инстансов и балансировки, принципы останутся теми же — просто добавится слой оркестрации.


