Выберите продукт

CI/CD для Node.js на VDS: GitHub Actions, SSH-деплой, rsync и откаты

Разбираем, как построить надежный CI/CD для Node.js на VDS: от подготовки сервера и systemd‑сервиса до деплоя из GitHub Actions по SSH через rsync. Покажу структуру релизов с симлинком, аккуратный перезапуск без простоя и быстрый rollback. Подходит для продакшна и стейджинга.
CI/CD для Node.js на VDS: GitHub Actions, SSH-деплой, rsync и откаты

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 не отключали проверку ключа.

FastFox VDS
Облачный VDS-сервер в России
Аренда виртуальных серверов с моментальным развертыванием инфраструктуры от 195₽ / мес

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‑сертификаты.

Структура каталогов релизов: releases, shared и симлинк current

Сценарий деплоя на сервере

Чтобы 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.

Пайплайн: GitHub Actions — SSH/rsync на VDS и откат релизов

Практические нюансы и устойчивость

  • Грациозное завершение. Обработчики 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% задач: предсказуемые релизы, быстрые откаты и минимум ручных действий. Когда вырастете до нескольких инстансов и балансировки, принципы останутся теми же — просто добавится слой оркестрации.

Поделиться статьей

Вам будет интересно

SSH зависает на Connecting и banner exchange: диагностика MTU, DNS, GSSAPI и KEX OpenAI Статья написана AI (GPT 5)

SSH зависает на Connecting и banner exchange: диагностика MTU, DNS, GSSAPI и KEX

Если SSH зависает на Connecting или banner exchange, причина обычно в конкретном шаге: проблемы TCP/фильтрации, MTU/PMTUD, задержк ...
TLS 1.3 и HTTP/2: что видно в ClientHello/ServerHello и как разбирать ошибки рукопожатия OpenAI Статья написана AI (GPT 5)

TLS 1.3 и HTTP/2: что видно в ClientHello/ServerHello и как разбирать ошибки рукопожатия

Практический разбор TLS 1.3 на уровне ClientHello/ServerHello: где увидеть SNI и ALPN, как сервер выбирает HTTP/2, почему возникае ...
Apache mod_remoteip: real IP клиента за reverse proxy без поломки логов OpenAI Статья написана AI (GPT 5)

Apache mod_remoteip: real IP клиента за reverse proxy без поломки логов

Когда Apache работает за reverse proxy, в логах и REMOTE_ADDR часто виден IP прокси, а не клиента. Разберём mod_remoteip: какой Re ...