CI/CD для Node.js + Docker с автотестами и деплоем на VPS
Готовый production-рецепт: один workflow.yml, секреты, Docker build, SSH deploy, .env, known_hosts, fail-fast и rollback. Почему деплой без тестов опасен, как хранить SSH-ключ и разбор security-рисков.
Требования
- Репозиторий на 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:
- Тесты и линт — если упали, дальше пайплайн не идёт (fail-fast).
- Сборка Docker-образа и пуш в GitHub Container Registry (GHCR).
- Деплой по 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 и перезапуск контейнера.
Кратко по блокам:
- test —
npm 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_HOST | IP или домен VPS | 192.168.1.10 или deploy.example.com |
SSH_USER | Пользователь SSH | deploy или root |
SSH_PRIVATE_KEY | Приватный SSH-ключ целиком | Содержимое id_ed25519 (без пароля) |
GITHUB_TOKEN создавать не нужно — он есть по умолчанию и даёт право пушить образы в GHCR этого репозитория.
Для pull образа на VPS есть два варианта:
- Один раз на сервере выполнить
docker login ghcr.io -u <GITHUB_USER> -p <PAT с read:packages>и хранить логин в~/.docker/config.json. Тогда в workflow в шаге Deploy не нужен логин — толькоdocker pullи перезапуск. - Передавать токен из CI — тогда в Secrets добавить
REGISTRY_TOKEN(PAT сread:packages) и в деплое делать логин, как в примере выше. Для production предпочтительнее вариант 1: токен на сервере один раз, в workflow не светится.
Как хранить SSH-ключ безопасно
Не делать:
- Класть приватный ключ в репозиторий (даже в приватный).
- Хранить ключ в переменных окружения в открытом виде в workflow (только через
secrets.*). - Использовать один ключ для всего (разработка, прод, другие сервисы).
Делать:
-
Отдельный ключ только для деплоя — сгенерировать новый ключ (например,
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. -
Права только на деплой — пользователь на сервере с ограниченной оболочкой или только право запускать фиксированный скрипт деплоя (через
authorized_keysсcommand="..."), без полного доступа в админку. -
Использовать ssh-agent в CI — как в примере с
webfactory/ssh-agent: ключ не записывается в файл на раннере, меньше шансов утечки в логах. -
Ротация — периодически менять ключ: новый ключ в
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 завершается с ошибкой.
- build —
needs: test. Запускается только при успешномtest. Если тесты не прошли, сборка образа не начнётся. - deploy —
needs: 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».



Комментарии