← Назад в блог

CI/CD для Node.js + Docker с автотестами и деплоем на VPS

Готовый production-рецепт: один workflow.yml, секреты, Docker build, SSH deploy, .env, known_hosts, fail-fast и rollback. Почему деплой без тестов опасен, как хранить SSH-ключ и разбор security-рисков.

CI/CD для Node.js + Docker с автотестами и деплоем на VPS

Требования

  • Репозиторий на GitHub
  • Node.js 18+
  • VPS с Docker и SSH
  • Базовое понимание YAML и командной строки

CI/CD для Node.js + Docker с автотестами и деплоем на VPS

Ниже — один сценарий от и до: один рабочий workflow.yml, секреты, сборка Docker-образа, деплой по SSH на VPS, пример .env, обход типичной ошибки с known_hosts, fail-fast и откат. Плюс: почему деплой без тестов опасен, как хранить SSH-ключ безопасно и разбор security-рисков. Статья — готовый production-рецепт, который можно скопировать и подставить свои значения.


Итоговая схема: что будет работать

После пуша в main:

  1. Тесты и линт — если упали, дальше пайплайн не идёт (fail-fast).
  2. Сборка Docker-образа и пуш в GitHub Container Registry (GHCR).
  3. Деплой по SSH на VPS: подтягивание образа и перезапуск контейнера.

Отдельно: как безопасно хранить секреты, что сделать с known_hosts и как откатиться при проблеме.


Почему без тестов деплой опасен

Деплой без прогона тестов в CI — это рулетка.

Что происходит по факту:

  • Локально «всё работает», на проде — падает из-за другой версии Node, другого окружения или забытого env.
  • Регрессия: поменяли один модуль — сломался другой; без тестов узнаешь об этом от пользователей.
  • Миграции и контракты API: тесты ловят поломку до того, как новый код попадёт на сервер.

Минимально разумный уровень: перед каждым деплоем в пайплайне запускать линтер и юнит/интеграционные тесты. Тогда в прод уезжает только код, который уже прошёл проверку в одном и том же окружении (CI). В этой схеме деплой-джоб выполняется только если тесты успешны — это и есть fail-fast на уровне пайплайна.


Один рабочий workflow.yml

Файл: .github/workflows/deploy.yml.

name: CI/CD — test, build, deploy

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io

jobs:
  test:
    name: Lint & test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Test
        run: npm test

  build:
    name: Docker build & push
    runs-on: ubuntu-latest
    needs: test
    permissions:
      contents: read
      packages: write
    outputs:
      image-digest: ${{ steps.meta.outputs.digest }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, digest)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ github.repository }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    name: Deploy to VPS
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Setup SSH
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Add server to known_hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy
        env:
          SSH_HOST: ${{ secrets.SSH_HOST }}
          SSH_USER: ${{ secrets.SSH_USER }}
          IMAGE: ${{ env.REGISTRY }}/${{ github.repository }}:latest
        run: |
          ssh $SSH_USER@$SSH_HOST "
            docker pull $IMAGE
            docker stop app || true
            docker rm app || true
            docker run -d --name app --restart unless-stopped \
              -p 3000:3000 \
              --env-file /opt/app/.env \
              $IMAGE
          "

На VPS один раз настрой доступ к GHCR: выполни echo <PAT с read:packages> | docker login ghcr.io -u <GITHUB_USER> --password-stdin и сохрани конфиг. Тогда в workflow не нужно передавать токен на сервер — в шаге Deploy остаются только docker pull и перезапуск контейнера.

Кратко по блокам:

  • testnpm ci, линт, тесты. При падении следующих job’ов не будет (fail-fast за счёт needs).
  • build — запускается только после test, собирает образ и пушит в GHCR с тегом latest и по SHA.
  • deploy — только после успешного build, поднимает SSH (ключ из секрета), добавляет хост в known_hosts, заходит на VPS и перезапускает контейнер.

В реальном проекте логин в registry на сервере лучше сделать один раз (см. раздел про секреты и .env), а в деплое только docker pull и перезапуск — тогда в workflow не нужно передавать GITHUB_TOKEN на сервер. Ниже даны вариант с преднастроенным логином и пример .env.


Секция Secrets: что создать в GitHub

В репозитории: Settings → Secrets and variables → Actions — создать:

SecretОписаниеПример (не подставлять как есть)
SSH_HOSTIP или домен VPS192.168.1.10 или deploy.example.com
SSH_USERПользователь SSHdeploy или root
SSH_PRIVATE_KEYПриватный SSH-ключ целикомСодержимое id_ed25519 (без пароля)

GITHUB_TOKEN создавать не нужно — он есть по умолчанию и даёт право пушить образы в GHCR этого репозитория.

Для pull образа на VPS есть два варианта:

  1. Один раз на сервере выполнить docker login ghcr.io -u <GITHUB_USER> -p <PAT с read:packages> и хранить логин в ~/.docker/config.json. Тогда в workflow в шаге Deploy не нужен логин — только docker pull и перезапуск.
  2. Передавать токен из CI — тогда в Secrets добавить REGISTRY_TOKEN (PAT с read:packages) и в деплое делать логин, как в примере выше. Для production предпочтительнее вариант 1: токен на сервере один раз, в workflow не светится.

Как хранить SSH-ключ безопасно

Не делать:

  • Класть приватный ключ в репозиторий (даже в приватный).
  • Хранить ключ в переменных окружения в открытом виде в workflow (только через secrets.*).
  • Использовать один ключ для всего (разработка, прод, другие сервисы).

Делать:

  1. Отдельный ключ только для деплоя — сгенерировать новый ключ (например, ed25519), не тот, с которым работаешь с Git:

    ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

    Приватную часть (deploy_key) целиком вставить в SSH_PRIVATE_KEY. Публичную (deploy_key.pub) — в ~/.ssh/authorized_keys на VPS для пользователя SSH_USER.

  2. Права только на деплой — пользователь на сервере с ограниченной оболочкой или только право запускать фиксированный скрипт деплоя (через authorized_keys с command="..."), без полного доступа в админку.

  3. Использовать ssh-agent в CI — как в примере с webfactory/ssh-agent: ключ не записывается в файл на раннере, меньше шансов утечки в логах.

  4. Ротация — периодически менять ключ: новый ключ в authorized_keys на сервере, обновить секрет в GitHub, старый удалить.


Разбор security-рисков

РискЧто делать
Утечка секретов в логНе выводить secrets.* в echo/run. GitHub маскирует значения, но случайный вывод может раскрыть часть.
SSH-ключ в репоКлюч только в GitHub Secrets. В коде — только ссылки вида ${{ secrets.SSH_PRIVATE_KEY }}.
Слишком широкие права GITHUB_TOKENВ job’ах указывать permissions: contents: read; packages: write только там, где нужен push в GHCR.
Деплой из форка или не из mainВ on.push.branches: [main] и не давать форкам писать в ветку. Ограничить запуск workflow только из основного репозитория.
Хост подставлен (MITM)Добавлять хост в known_hosts через ssh-keyscan (как в примере), чтобы не отключать проверку навсегда (StrictHostKeyChecking=no только при необходимости и только в CI).
Токен registry на сервереХранить в защищённом месте (файл с правами 600), не в коде и не в публичном месте. Для GHCR — PAT с минимальным scope read:packages.

Итог: один деплой-пользователь, один ключ, секреты только в GitHub Secrets, минимальные права токенов и хоста в known_hosts — базовый production-уровень.


Пример .env для приложения и сервера

В репозитории должен быть шаблон без секретов — чтобы любой мог поднять проект локально и в CI не светились реальные значения.

Файл в репо: .env.example

NODE_ENV=production
PORT=3000
LOG_LEVEL=info

# База (значения подставить локально / на сервере)
DATABASE_URL=
REDIS_URL=

# Внешние API (опционально)
API_KEY=

На VPS в каталоге приложения (например, /opt/app/) лежит реальный .env с подставленными значениями. В docker run используем --env-file /opt/app/.env, как в примере workflow. Файл .env в репозиторий не коммитить, в .gitignore — обязательно строка .env.


Типичная ошибка с known_hosts

Симптом: при выполнении ssh в шаге Deploy падает ошибка:

Host key verification failed.

Причина: на раннере GitHub Actions при первом подключении к хосту нет записи о нём в ~/.ssh/known_hosts, и по умолчанию SSH отказывается подключаться.

Решение: перед вызовом ssh добавить хост в known_hosts:

- name: Add server to known_hosts
  run: |
    mkdir -p ~/.ssh
    ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

-H приводит хост к каноническому виду (по IP), так что и при доступе по домену запись будет использоваться. После этого шага ssh $SSH_USER@$SSH_HOST ... перестаёт падать на проверке хоста.

Не делать в проде: не отключать проверку глобально (StrictHostKeyChecking=no в ~/.ssh/config) на своей машине; в CI — только если иначе нельзя (например, динамический хост), и то точечно.


Fail-fast стратегия

В предложенном workflow fail-fast достигается за счёт цепочки needs:

  • test — выполняется первым. При падении линта или тестов job завершается с ошибкой.
  • buildneeds: test. Запускается только при успешном test. Если тесты не прошли, сборка образа не начнётся.
  • deployneeds: build. Запускается только при успешной сборке и пушу в GHCR.

Итог: в прод уезжает только код, прошедший тесты и собранный в образ. Никаких отдельных флагов в YAML для этого не нужно — достаточно не запускать деплой при падении предыдущих job’ов.

Если позже добавишь strategy.matrix (например, несколько версий Node), можно явно включить отмену остальных при падении одного варианта:

jobs:
  test:
    strategy:
      fail-fast: true
      matrix:
        node: [18, 20]

Тогда при падении одного из вариантов второй будет отменён.


Пример rollback (откат)

Если после деплоя что-то сломалось, откатиться можно так.

Вариант 1: перезапустить предыдущий образ по тегу (SHA).

В workflow образ тегируется по коммиту (type=sha,prefix=). На сервере сохраняем предыдущий тег перед обновлением или смотрим в GHCR. Откат вручную по SSH:

ssh $SSH_USER@$SSH_HOST "
  docker pull $REGISTRY/$REPO:<previous-sha>
  docker stop app && docker rm app
  docker run -d --name app --restart unless-stopped -p 3000:3000 --env-file /opt/app/.env $REGISTRY/$REPO:<previous-sha>
"

Вариант 2: откат через Git и повторный деплой.

Откатить коммит и запушить в main — CI снова прогонит тесты и задеплоит уже старую версию:

git revert HEAD --no-edit
git push origin main

После успешного прохода workflow на сервере будет образ, собранный от откатанного коммита.

Вариант 3: ручной workflow для отката.

Можно завести отдельный workflow с workflow_dispatch, куда передаёшь тег или SHA образа и на сервере делаешь docker pull этого тега и перезапуск. Удобно, когда хочешь откатиться без реверта коммита.


Чеклист перед продом

  • Секреты SSH_HOST, SSH_USER, SSH_PRIVATE_KEY заданы в GitHub Actions, не в коде.
  • На VPS в authorized_keys добавлен только публичный ключ деплоя, пользователь с минимальными правами.
  • Деплой идёт только из ветки main (on.push.branches: [main]).
  • Тесты и линт обязательны; при их падении деплой не выполняется.
  • Для pull образа на сервере: либо один раз настроен docker login ghcr.io, либо используется отдельный PAT с read:packages, не основной пароль.
  • .env на сервере с правами 600, не в репозитории; в репо только .env.example.
  • Известен способ отката (по тегу образа или через git revert + пуш).

Итог

Один workflow: тесты → сборка Docker → пуш в GHCR → деплой по SSH на VPS. Секреты в GitHub, SSH через агент и known_hosts, образы по тегу и latest, откат — по предыдущему тегу или реверту коммита. Такой сценарий можно использовать как готовый production-рецепт и подстраивать под свой репо и сервер.

Для PHP/Bitrix/Laravel те же идеи (один пайплайн, секреты, SSH, откат) переносятся с заменой шагов на Composer и свои тесты — см. «CI/CD для PHP, Bitrix и Laravel».

0 просмотров

Комментарии

Загрузка комментариев...
Пока нет комментариев. Будьте первым!