GitHub Actions CI/CD: деплой без простоя, который реально работает
Моя полная настройка GitHub Actions: параллельные тестовые джобы, кэширование Docker-сборок, SSH-деплой на VPS, zero-downtime через PM2 reload, управление секретами и паттерны воркфлоу, отточенные за два года.
Каждый проект, над которым я работал, рано или поздно достигает одной и той же точки перелома: процесс деплоя становится слишком мучительным, чтобы делать его вручную. Вы забываете запустить тесты. Вы собираете локально, но забываете обновить версию. Вы заходите по SSH на продакшен и обнаруживаете, что тот, кто деплоил последним, оставил устаревший .env файл.
GitHub Actions решил эту проблему для меня два года назад. Не идеально с первого дня — первый воркфлоу, который я написал, был 200-строчным YAML-кошмаром, который в половине случаев падал по таймауту и ничего не кэшировал. Но итерация за итерацией я пришёл к тому, что деплоит этот сайт надёжно, без простоя, менее чем за четыре минуты.
Это тот самый воркфлоу, объяснённый секция за секцией. Не версия из документации. Версия, которая выживает при столкновении с продакшеном.
Понимание строительных блоков#
Прежде чем перейти к полному пайплайну, вам нужна чёткая ментальная модель того, как работает GitHub Actions. Если вы использовали Jenkins или CircleCI, забудьте большую часть того, что знаете. Концепции пересекаются приблизительно, но модель исполнения достаточно отличается, чтобы вас подловить.
Триггеры: когда запускается ваш воркфлоу#
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # Каждый понедельник в 6:00 UTC
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: "staging"
type: choice
options:
- staging
- productionЧетыре триггера, каждый служит своей цели:
pushвmain— это триггер деплоя в продакшен. Код смерджен? Выкатываем.pull_requestзапускает CI-проверки на каждом PR. Здесь живут линтинг, проверка типов и тесты.schedule— это cron для вашего репозитория. Я использую его для еженедельных аудитов зависимостей и очистки устаревшего кэша.workflow_dispatchдаёт вам кнопку ручного «Deploy» в интерфейсе GitHub с входными параметрами. Незаменим, когда нужно задеплоить стейджинг без изменения кода — может, вы обновили переменную окружения или нужно перетянуть базовый Docker-образ.
Одна вещь, которая подлавливает людей: pull_request запускается против мерж-коммита, а не HEAD ветки PR. Это означает, что ваш CI тестирует, как код будет выглядеть после мерджа. На самом деле это именно то, что вам нужно, но удивляет, когда зелёная ветка становится красной после ребейза.
Джобы, шаги и раннеры#
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lintДжобы по умолчанию запускаются параллельно. Каждая джоба получает свежую виртуальную машину («раннер»). ubuntu-latest даёт вам достаточно мощную машину — 4 vCPU, 16 ГБ ОЗУ по состоянию на 2026 год. Бесплатно для публичных репозиториев, 2000 минут/месяц для приватных.
Шаги выполняются последовательно внутри джобы. Каждый шаг uses: подключает переиспользуемый экшен из маркетплейса. Каждый шаг run: выполняет команду оболочки.
Флаг --frozen-lockfile критически важен. Без него pnpm install может обновить ваш lockfile во время CI, что означает, что вы тестируете не те же зависимости, которые закоммитил разработчик. Я видел, как это вызывает фантомные падения тестов, которые исчезают локально, потому что lockfile на машине разработчика уже корректен.
Переменные окружения vs секреты#
env:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: 1
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
echo "$SSH_PRIVATE_KEY" > key.pem
chmod 600 key.pem
ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"Переменные окружения, установленные через env: на уровне воркфлоу — это обычный текст, видимый в логах. Используйте их для нечувствительной конфигурации: NODE_ENV, флаги телеметрии, фиче-тогглы.
Секреты (${{ secrets.X }}) зашифрованы в хранилище, замаскированы в логах и доступны только воркфлоу в том же репозитории. Они настраиваются в Settings > Secrets and variables > Actions.
Строка environment: production значима. GitHub Environments позволяет привязывать секреты к конкретным целям деплоя. Ваш SSH-ключ для стейджинга и SSH-ключ для продакшена могут оба называться SSH_PRIVATE_KEY, но содержать разные значения в зависимости от того, какое окружение указано для джобы. Это также открывает возможность обязательных ревьюеров — вы можете заблокировать деплой в продакшен ручным одобрением.
Полный CI-пайплайн#
Вот как я структурирую CI-часть пайплайна. Цель: поймать каждую категорию ошибки за минимально возможное время.
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm tsc --noEmit
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7
build:
name: Build
needs: [lint, typecheck, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/
retention-days: 1Почему такая структура#
Lint, typecheck и test запускаются параллельно. Они не зависят друг от друга. Ошибка типов не блокирует работу линтера, и упавший тест не ждёт завершения проверки типов. При типичном запуске все три завершаются за 30-60 секунд, работая одновременно.
Build ожидает все три. Строка needs: [lint, typecheck, test] означает, что джоба сборки начнётся только если lint, typecheck И test — все пройдут успешно. Нет смысла собирать проект с ошибками линтера или типов.
concurrency с cancel-in-progress: true — огромная экономия времени. Если вы пушите два коммита подряд, первый CI-запуск отменяется. Без этого устаревшие запуски будут расходовать ваш бюджет минут и захламлять UI проверок.
Загрузка покрытия с if: always() означает, что вы получаете отчёт о покрытии даже когда тесты падают. Это полезно для отладки — вы можете видеть, какие тесты упали и что они покрывали.
Быстрый отказ vs запуск всех#
По умолчанию, если одна джоба в матрице падает, GitHub отменяет остальные. Для CI мне на самом деле нужно такое поведение — если линтер упал, мне неинтересны результаты тестов. Сначала исправьте линтер.
Но для матриц тестов (скажем, тестирование на Node 20 и Node 22) вы можете захотеть видеть все ошибки сразу:
test:
strategy:
fail-fast: false
matrix:
node-version: [20, 22]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm testfail-fast: false позволяет обоим вариантам матрицы завершиться. Если Node 22 падает, а Node 20 проходит, вы видите эту информацию сразу, вместо того чтобы перезапускать.
Кэширование для скорости#
Самое значительное улучшение скорости CI — это кэширование. Холодный pnpm install на проекте среднего размера занимает 30-45 секунд. С прогретым кэшем — 3-5 секунд. Умножьте это на четыре параллельные джобы, и вы экономите две минуты на каждом запуске.
Кэш хранилища pnpm#
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"Эта одна строка кэширует хранилище pnpm (~/.local/share/pnpm/store). При попадании в кэш pnpm install --frozen-lockfile просто создаёт жёсткие ссылки из хранилища вместо скачивания. Одно это сокращает время установки на 80% при повторных запусках.
Если вам нужно больше контроля — скажем, вы хотите кэшировать с учётом ОС — используйте actions/cache напрямую:
- uses: actions/cache@v4
with:
path: |
~/.local/share/pnpm/store
node_modules
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-Фоллбэк через restore-keys важен. Если pnpm-lock.yaml изменился (новая зависимость), точный ключ не совпадёт, но совпадение по префиксу всё равно восстановит большинство закэшированных пакетов. Скачается только разница.
Кэш сборки Next.js#
У Next.js есть собственный кэш сборки в .next/cache. Кэширование его между запусками обеспечивает инкрементальные сборки — перекомпилируются только изменённые страницы и компоненты.
- uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
nextjs-${{ runner.os }}-Эта трёхуровневая стратегия ключей означает:
- Точное совпадение: те же зависимости И тот же исходный код. Полное попадание в кэш, сборка почти мгновенная.
- Частичное совпадение (зависимости): зависимости те же, но исходный код изменился. Сборка перекомпилирует только изменённые файлы.
- Частичное совпадение (только ОС): зависимости изменились. Сборка переиспользует что может.
Реальные цифры из моего проекта: холодная сборка занимает ~55 секунд, закэшированная — ~15 секунд. Это снижение на 73%.
Кэширование слоёв Docker#
Docker-сборки — это где кэширование даёт максимальный эффект. Полная Docker-сборка Next.js — установка зависимостей ОС, копирование исходников, запуск pnpm install, запуск next build — занимает 3-4 минуты вхолодную. С кэшированием слоёв — 30-60 секунд.
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=maxtype=gha использует встроенный бэкенд кэширования GitHub Actions. mode=max кэширует все слои, а не только финальные. Это критически важно для многоступенчатых сборок, где промежуточные слои (вроде pnpm install) — самые дорогие для пересборки.
Удалённый кэш Turborepo#
Если вы в монорепозитории с Turborepo, удалённое кэширование — это трансформация. Первая сборка загружает результаты задач в кэш. Последующие сборки скачивают вместо пересчёта.
- run: pnpm turbo build --remote-only
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Я видел, как CI монорепозитория снижался с 8 минут до 90 секунд с удалённым кэшем Turbo. Но есть нюанс: требуется аккаунт Vercel или self-hosted сервер Turbo. Для одно-приложенческих репозиториев это избыточно.
Сборка и пуш Docker-образа#
Если вы деплоите на VPS (или любой сервер), Docker обеспечивает воспроизводимые сборки. Тот же образ, что запускается в CI — тот же образ, что работает в продакшене. Больше никакого «у меня работает», потому что машина и есть образ.
Многоступенчатый Dockerfile#
Прежде чем перейти к воркфлоу, вот Dockerfile, который я использую для Next.js:
# Этап 1: Зависимости
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
# Этап 2: Сборка
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Этап 3: Продакшен
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]Три этапа, чёткое разделение. Финальный образ ~150МБ вместо ~1.2ГБ, которые получились бы при копировании всего. Только продакшен-артефакты попадают на этап runner.
Воркфлоу сборки и пуша#
name: Build and Push Docker Image
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxДавайте разберём ключевые решения.
GitHub Container Registry (ghcr.io)#
Я использую ghcr.io вместо Docker Hub по трём причинам:
- Аутентификация бесплатна.
GITHUB_TOKENавтоматически доступен в каждом воркфлоу — не нужно хранить учётные данные Docker Hub. - Близость. Образы скачиваются из той же инфраструктуры, на которой работает ваш CI. Скачивание во время CI — быстрое.
- Видимость. Образы привязаны к вашему репозиторию в интерфейсе GitHub. Вы видите их во вкладке Packages.
Мультиплатформенные сборки#
platforms: linux/amd64,linux/arm64Эта строка добавляет примерно 90 секунд к сборке, но оно того стоит. Образы ARM64 нативно запускаются на:
- Apple Silicon Mac (M1/M2/M3/M4) при локальной разработке с Docker Desktop
- Инстансах AWS Graviton (на 20-40% дешевле x86-эквивалентов)
- Бесплатном ARM-тире Oracle Cloud
Без этого ваши разработчики на Mac серии M запускают x86-образы через эмуляцию Rosetta. Это работает, но заметно медленнее и иногда выявляет странные баги, специфичные для архитектуры.
QEMU обеспечивает слой кросс-компиляции. Buildx оркестрирует мультиархитектурную сборку и пушит список манифестов, чтобы Docker автоматически скачивал правильную архитектуру.
Стратегия тегирования#
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}Каждый образ получает три тега:
abc1234(SHA коммита): неизменяемый. Вы всегда можете задеплоить конкретный коммит.main(имя ветки): изменяемый. Указывает на последнюю сборку из этой ветки.latest: изменяемый. Устанавливается только для ветки по умолчанию. Это то, что тянет ваш сервер.
Никогда не деплойте latest в продакшен, не записав SHA где-нибудь. Когда что-то сломается, вам нужно знать, какой именно latest. Я сохраняю задеплоенный SHA в файле на сервере, который читает health endpoint.
SSH-деплой на VPS#
Здесь всё соединяется воедино. CI пройден, Docker-образ собран и запушен, теперь нужно сказать серверу скачать новый образ и перезапуститься.
SSH-экшен#
deploy:
name: Deploy to Production
needs: [build-and-push]
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
script: |
set -euo pipefail
APP_DIR="/var/www/akousa.net"
IMAGE="ghcr.io/${{ github.repository }}:latest"
DEPLOY_SHA="${{ github.sha }}"
echo "=== Деплой $DEPLOY_SHA ==="
# Скачать последний образ
docker pull "$IMAGE"
# Остановить и удалить старый контейнер
docker stop akousa-app || true
docker rm akousa-app || true
# Запустить новый контейнер
docker run -d \
--name akousa-app \
--restart unless-stopped \
--network host \
-e NODE_ENV=production \
-e DATABASE_URL="${DATABASE_URL}" \
-p 3000:3000 \
"$IMAGE"
# Ожидание проверки здоровья
echo "Ожидание проверки здоровья..."
for i in $(seq 1 30); do
if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
echo "Проверка здоровья пройдена на попытке $i"
break
fi
if [ "$i" -eq 30 ]; then
echo "Проверка здоровья не пройдена после 30 попыток"
exit 1
fi
sleep 2
done
# Записать задеплоенный SHA
echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
# Очистить старые образы
docker image prune -af --filter "until=168h"
echo "=== Деплой завершён ==="Альтернатива: скрипт деплоя#
Для чего-то более сложного, чем простой pull-and-restart, я выношу логику в скрипт на сервере, вместо того чтобы встраивать её в воркфлоу:
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
log "Начало деплоя..."
# Логин в GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
# Скачивание с повтором
for attempt in 1 2 3; do
if docker pull "$IMAGE"; then
log "Образ успешно скачан на попытке $attempt"
break
fi
if [ "$attempt" -eq 3 ]; then
log "ОШИБКА: Не удалось скачать образ после 3 попыток"
exit 1
fi
log "Попытка $attempt не удалась, повтор через 5с..."
sleep 5
done
# Функция проверки здоровья
health_check() {
local port=$1
local max_attempts=30
for i in $(seq 1 $max_attempts); do
if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
# Запуск нового контейнера на альтернативном порту
docker run -d \
--name akousa-app-new \
--env-file "$APP_DIR/.env.production" \
-p 3001:3000 \
"$IMAGE"
# Проверка здоровья нового контейнера
if ! health_check 3001; then
log "ОШИБКА: Новый контейнер не прошёл проверку здоровья. Откат."
docker stop akousa-app-new || true
docker rm akousa-app-new || true
exit 1
fi
log "Новый контейнер здоров. Переключение трафика..."
# Переключение upstream в Nginx
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# Остановка старого контейнера
docker stop akousa-app || true
docker rm akousa-app || true
# Переименование нового контейнера
docker rename akousa-app-new akousa-app
log "Деплой завершён."Воркфлоу тогда сводится к одной SSH-команде:
script: |
cd /var/www/akousa.net && ./deploy.shЭто лучше, потому что: (1) логика деплоя версионирована на сервере, (2) вы можете запустить её вручную через SSH для отладки, и (3) вам не нужно экранировать YAML внутри YAML внутри bash.
Стратегии нулевого простоя#
«Нулевой простой» звучит как маркетинг, но имеет точное значение: ни один запрос не получает connection refused или 502 во время деплоя. Вот три реальных подхода, от простейшего к наиболее надёжному.
Стратегия 1: PM2 Cluster Mode Reload#
Если вы запускаете Node.js напрямую (не в Docker), кластерный режим PM2 даёт вам самый простой путь к нулевому простою.
# ecosystem.config.js уже содержит:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 reload (не restart) выполняет роллинг-рестарт. Он запускает новых воркеров, ждёт их готовности, затем убивает старых воркеров по одному. Ни в один момент количество воркеров, обслуживающих трафик, не равно нулю.
Флаг --update-env перезагружает переменные окружения из конфига экосистемы. Без него ваши старые переменные сохраняются даже после деплоя, который изменил .env.
В вашем воркфлоу:
- name: Deploy and reload PM2
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/akousa.net
git pull origin main
pnpm install --frozen-lockfile
pnpm build
pm2 reload ecosystem.config.js --update-envЭто то, что я использую для этого сайта. Просто, надёжно, и простой — буквально ноль. Я тестировал это с генератором нагрузки, отправляющим 100 запросов/сек во время деплоя. Ни одной 5xx.
Стратегия 2: Blue/Green с Nginx Upstream#
Для Docker-деплоев blue/green даёт чёткое разделение между старой и новой версией.
Концепция: запустить старый контейнер («blue») на порту 3000, а новый («green») на порту 3001. Nginx указывает на blue. Вы запускаете green, проверяете его здоровье, переключаете Nginx на green, затем останавливаете blue.
Конфигурация Nginx upstream:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /etc/nginx/sites-available/akousa.net
server {
listen 443 ssl http2;
server_name akousa.net;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
}
}Скрипт переключения:
#!/bin/bash
set -euo pipefail
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
if [ "$CURRENT_PORT" = "3000" ]; then
NEW_PORT=3001
OLD_PORT=3000
else
NEW_PORT=3000
OLD_PORT=3001
fi
echo "Текущий: $OLD_PORT -> Новый: $NEW_PORT"
# Запуск нового контейнера на альтернативном порту
docker run -d \
--name "akousa-app-$NEW_PORT" \
--env-file /var/www/akousa.net/.env.production \
-p "$NEW_PORT:3000" \
"ghcr.io/akousa/akousa-net:latest"
# Ожидание здоровья
for i in $(seq 1 30); do
if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
echo "Новый контейнер здоров на порту $NEW_PORT"
break
fi
[ "$i" -eq 30 ] && { echo "Проверка здоровья не пройдена"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
sleep 2
done
# Переключение Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# Остановка старого контейнера
sleep 5 # Дать время завершиться текущим запросам
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
echo "Переключено с :$OLD_PORT на :$NEW_PORT"5-секундный sleep после перезагрузки Nginx — это не лень, а время на завершение. Перезагрузка Nginx — graceful (существующие соединения сохраняются), но некоторым long-polling соединениям или потоковым ответам нужно время для завершения.
Стратегия 3: Docker Compose с проверками здоровья#
Для более структурированного подхода Docker Compose может управлять blue/green переключением:
# docker-compose.yml
services:
app:
image: ghcr.io/akousa/akousa-net:latest
restart: unless-stopped
env_file: .env.production
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
order: start-first
failure_action: rollback
rollback_config:
parallelism: 0
order: stop-first
ports:
- "3000:3000"Ключевая строка — order: start-first. Она означает «запусти новый контейнер до остановки старого». В сочетании с parallelism: 1 вы получаете роллинг-обновление — по одному контейнеру за раз, всегда поддерживая ёмкость.
Деплой командой:
docker compose pull
docker compose up -d --remove-orphansDocker Compose наблюдает за healthcheck и не направляет трафик на новый контейнер, пока он не пройдёт проверку. Если healthcheck падает, failure_action: rollback автоматически откатывает на предыдущую версию. Это максимально близко к роллинг-деплоям в стиле Kubernetes, что можно получить на одном VPS.
Управление секретами#
Управление секретами — это одна из тех вещей, которые легко сделать «почти правильно» и катастрофически неправильно в оставшихся граничных случаях.
GitHub Secrets: основы#
# Устанавливаются через интерфейс GitHub: Settings > Secrets and variables > Actions
steps:
- name: Use a secret
env:
DB_URL: ${{ secrets.DATABASE_URL }}
run: |
# Значение маскируется в логах
echo "Подключение к базе данных..."
# Это напечатает "Connecting to ***" в логах
echo "Connecting to $DB_URL"GitHub автоматически маскирует значения секретов в выводе логов. Если ваш секрет p@ssw0rd123 и любой шаг печатает эту строку, в логах будет ***. Это работает хорошо, с одной оговоркой: если ваш секрет короткий (например, 4-значный PIN), GitHub может его не замаскировать, потому что он может совпасть с безобидными строками. Делайте секреты достаточно сложными.
Секреты с привязкой к окружению#
jobs:
deploy-staging:
environment: staging
steps:
- run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
# DEPLOY_HOST = staging.akousa.net
deploy-production:
environment: production
steps:
- run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
# DEPLOY_HOST = akousa.netОдно и то же имя секрета, разные значения для каждого окружения. Поле environment джобы определяет, какой набор секретов внедряется.
Продакшен-окружения должны иметь включённую опцию required reviewers. Это означает, что пуш в main запускает воркфлоу, CI работает автоматически, но джоба деплоя приостанавливается и ждёт, пока кто-то нажмёт «Approve» в интерфейсе GitHub. Для сольного проекта это может казаться избыточным. Для всего, что имеет пользователей, это спасение, когда вы случайно мерджите что-то сломанное.
OIDC: больше никаких статических учётных данных#
Статические учётные данные (ключи доступа AWS, JSON-файлы сервисных аккаунтов GCP), хранящиеся в GitHub Secrets — это ответственность. Они не истекают, их нельзя ограничить конкретным запуском воркфлоу, и если они утекут, придётся ротировать их вручную.
OIDC (OpenID Connect) решает эту проблему. GitHub Actions выступает как провайдер идентификации, а ваш облачный провайдер доверяет ему выпускать короткоживущие учётные данные на лету:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Требуется для OIDC
contents: read
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: eu-central-1
- name: Push to ECR
run: |
aws ecr get-login-password --region eu-central-1 | \
docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.comНикаких ключей доступа. Никаких секретных ключей. Экшен configure-aws-credentials запрашивает временный токен у AWS STS, используя OIDC-токен GitHub. Токен привязан к конкретному репозиторию, ветке и окружению. Он истекает после завершения запуска воркфлоу.
Настройка на стороне AWS требует IAM OIDC identity provider и политики доверия роли:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
}
}
}
]
}Условие sub критически важно. Без него любой репозиторий, каким-то образом получивший данные вашего OIDC-провайдера, мог бы принять роль. С ним только ветка main вашего конкретного репозитория может.
У GCP есть эквивалентная настройка с Workload Identity Federation. У Azure — федеративные учётные данные. Если ваш облачный провайдер поддерживает OIDC, используйте его. В 2026 году нет причин хранить статические облачные учётные данные.
SSH-ключи деплоя#
Для деплоев на VPS через SSH сгенерируйте отдельную пару ключей:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""Добавьте публичный ключ в ~/.ssh/authorized_keys сервера с ограничениями:
restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy
Префикс restrict отключает перенаправление портов, перенаправление агента, выделение PTY и перенаправление X11. Префикс command= означает, что этот ключ может только выполнить скрипт деплоя. Даже если приватный ключ скомпрометирован, атакующий может запустить ваш скрипт деплоя и ничего больше.
Добавьте приватный ключ в GitHub Secrets как SSH_PRIVATE_KEY. Это единственные статические учётные данные, которые я допускаю — SSH-ключи с принудительными командами имеют очень ограниченный радиус поражения.
Воркфлоу PR: превью-деплои#
Каждый PR заслуживает превью-окружения. Оно ловит визуальные баги, которые юнит-тесты пропускают, позволяет дизайнерам делать ревью без клонирования кода и значительно упрощает жизнь QA.
Деплой превью при открытии PR#
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
preview:
runs-on: ubuntu-latest
environment:
name: preview-${{ github.event.number }}
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v4
- name: Build preview image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Deploy preview
id: deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PREVIEW_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
PR_NUM=${{ github.event.number }}
PORT=$((4000 + PR_NUM))
IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
docker pull "$IMAGE"
docker stop "preview-${PR_NUM}" || true
docker rm "preview-${PR_NUM}" || true
docker run -d \
--name "preview-${PR_NUM}" \
--restart unless-stopped \
-e NODE_ENV=preview \
-p "${PORT}:3000" \
"$IMAGE"
echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
- name: Comment PR with preview URL
uses: actions/github-script@v7
with:
script: |
const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
const body = `### Preview Deployment
| Status | URL |
|--------|-----|
| :white_check_mark: Deployed | [${url}](${url}) |
_Last updated: ${new Date().toISOString()}_
_Commit: \`${{ github.sha }}\`_`;
// Find existing comment
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(c =>
c.user.type === 'Bot' && c.body.includes('Preview Deployment')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}Вычисление порта (4000 + PR_NUM) — прагматичный хак. PR #42 получает порт 4042. Пока у вас нет нескольких сотен открытых PR, коллизий не будет. Конфигурация Nginx с wildcard маршрутизирует pr-*.preview.akousa.net на правильный порт.
Очистка при закрытии PR#
Превью-окружения, которые не очищаются, потребляют диск и память. Добавьте джобу очистки:
name: Cleanup Preview
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Remove preview container
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PREVIEW_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
PR_NUM=${{ github.event.number }}
docker stop "preview-${PR_NUM}" || true
docker rm "preview-${PR_NUM}" || true
docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
echo "Превью для PR #${PR_NUM} очищено."
- name: Deactivate environment
uses: actions/github-script@v7
with:
script: |
const deployments = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: `preview-${{ github.event.number }}`,
});
for (const deployment of deployments.data) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'inactive',
});
}Обязательные проверки статуса#
В настройках репозитория (Settings > Branches > Branch protection rules) потребуйте эти проверки перед мерджем:
lint— нет ошибок линтераtypecheck— нет ошибок типовtest— все тесты проходятbuild— проект успешно собирается
Без этого кто-нибудь обязательно мерджнет PR с упавшими проверками. Не злонамеренно — они увидят «2 из 4 проверок пройдено» и решат, что остальные две ещё выполняются. Заблокируйте это.
Также включите «Require branches to be up to date before merging.» Это принуждает перезапуск CI после ребейза на последний main. Это ловит случай, когда два PR по отдельности проходят CI, но конфликтуют при объединении.
Уведомления#
Деплой, о котором никто не знает — это деплой, которому никто не доверяет. Уведомления замыкают обратную связь.
Slack Webhook#
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Repository:*\n${{ github.repository }}"
},
{
"type": "mrkdwn",
"text": "*Branch:*\n${{ github.ref_name }}"
},
{
"type": "mrkdwn",
"text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
},
{
"type": "mrkdwn",
"text": "*Triggered by:*\n${{ github.actor }}"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "View Run"
},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
]
}if: always() — критически важно. Без него шаг уведомления пропускается, когда деплой падает — а это именно тот момент, когда оно нужно больше всего.
GitHub Deployments API#
Для более богатого отслеживания деплоев используйте GitHub Deployments API. Это даёт вам историю деплоев в интерфейсе репозитория и позволяет ставить статус-бейджи:
- name: Create GitHub Deployment
id: deployment
uses: actions/github-script@v7
with:
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.sha,
environment: 'production',
auto_merge: false,
required_contexts: [],
description: `Deploying ${context.sha.substring(0, 7)} to production`,
});
return deployment.data.id;
- name: Deploy
run: |
# ... собственно шаги деплоя ...
- name: Update deployment status
if: always()
uses: actions/github-script@v7
with:
script: |
const deploymentId = ${{ steps.deployment.outputs.result }};
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deploymentId,
state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
environment_url: 'https://akousa.net',
log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
description: '${{ job.status }}' === 'success'
? 'Deployment succeeded'
: 'Deployment failed',
});Теперь вкладка Environments в GitHub показывает полную историю деплоев: кто что деплоил, когда, и успешно ли.
Email только при ошибке#
Для критических деплоев я также отправляю email при ошибке. Не через встроенный email GitHub Actions (слишком шумный), а через целевой вебхук:
- name: Alert on failure
if: failure()
run: |
curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d '{
"subject": "DEPLOY FAILED: ${{ github.repository }}",
"body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}'Это моя последняя линия обороны. Slack хорош, но он тоже шумный — люди отключают уведомления в каналах. Email «DEPLOY FAILED» со ссылкой на запуск привлекает внимание.
Полный файл воркфлоу#
Вот всё, соединённое в единый, готовый к продакшену воркфлоу. Это очень близко к тому, что реально деплоит этот сайт.
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
skip_tests:
description: "Skip tests (emergency deploy)"
required: false
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
NODE_VERSION: "22"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ============================================================
# CI: Lint, проверка типов и тесты параллельно
# ============================================================
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run ESLint
run: pnpm lint
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run TypeScript compiler
run: pnpm tsc --noEmit
test:
name: Unit Tests
if: ${{ !inputs.skip_tests }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests with coverage
run: pnpm test -- --coverage
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
# ============================================================
# Build: только после прохождения CI
# ============================================================
build:
name: Build Application
needs: [lint, typecheck, test]
if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
nextjs-${{ runner.os }}-
- name: Build Next.js application
run: pnpm build
# ============================================================
# Docker: сборка и пуш образа (только ветка main)
# ============================================================
docker:
name: Build Docker Image
needs: [build]
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU for multi-platform builds
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ============================================================
# Deploy: SSH на VPS и обновление
# ============================================================
deploy:
name: Deploy to Production
needs: [docker]
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
environment:
name: production
url: https://akousa.net
steps:
- name: Create GitHub Deployment
id: deployment
uses: actions/github-script@v7
with:
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.sha,
environment: 'production',
auto_merge: false,
required_contexts: [],
description: `Deploy ${context.sha.substring(0, 7)}`,
});
return deployment.data.id;
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
command_timeout: 5m
script: |
set -euo pipefail
APP_DIR="/var/www/akousa.net"
IMAGE="ghcr.io/${{ github.repository }}:latest"
SHA="${{ github.sha }}"
echo "=== Деплой $SHA начат $(date) ==="
# Скачать новый образ
docker pull "$IMAGE"
# Запустить новый контейнер на альтернативном порту
docker run -d \
--name akousa-app-new \
--env-file "$APP_DIR/.env.production" \
-p 3001:3000 \
"$IMAGE"
# Проверка здоровья
echo "Запуск проверки здоровья..."
for i in $(seq 1 30); do
if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
echo "Проверка здоровья пройдена (попытка $i)"
break
fi
if [ "$i" -eq 30 ]; then
echo "ОШИБКА: Проверка здоровья не пройдена"
docker logs akousa-app-new --tail 50
docker stop akousa-app-new && docker rm akousa-app-new
exit 1
fi
sleep 2
done
# Переключение трафика
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# Время завершения текущих запросов
sleep 5
# Остановка старого контейнера
docker stop akousa-app || true
docker rm akousa-app || true
# Переименование и сброс порта
docker rename akousa-app-new akousa-app
sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
# Примечание: мы не перезагружаем Nginx здесь, потому что изменилось имя контейнера,
# а не порт. Следующий деплой будет использовать правильный порт.
# Запись деплоя
echo "$SHA" > "$APP_DIR/.deployed-sha"
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
# Очистка старых образов (старше 7 дней)
docker image prune -af --filter "until=168h"
echo "=== Деплой завершён $(date) ==="
- name: Update deployment status
if: always()
uses: actions/github-script@v7
with:
script: |
const deploymentId = ${{ steps.deployment.outputs.result }};
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deploymentId,
state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
environment_url: 'https://akousa.net',
log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
});
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
},
{
"type": "mrkdwn",
"text": "*Actor:*\n${{ github.actor }}"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "View Run" },
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
]
}
- name: Alert on failure
if: failure()
run: |
curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d '{
"subject": "DEPLOY FAILED: ${{ github.repository }}",
"body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}' || trueПошаговое описание процесса#
Когда я пушу в main:
- Lint, Type Check и Test запускаются одновременно. Три раннера, три параллельные джобы. Если любая из них падает, пайплайн останавливается.
- Build запускается только если все три прошли. Он проверяет, что приложение компилируется и даёт рабочий результат.
- Docker собирает продакшен-образ и пушит его в ghcr.io. Мультиплатформенный, с кэшированием слоёв.
- Deploy подключается по SSH к VPS, скачивает новый образ, запускает новый контейнер, проверяет его здоровье, переключает Nginx и выполняет очистку.
- Уведомления отправляются независимо от результата. Slack получает сообщение. GitHub Deployments обновляется. Если деплой упал, отправляется email-алерт.
Когда я открываю PR:
- Lint, Type Check и Test запускаются. Те же проверки качества.
- Build запускается для проверки, что проект компилируется.
- Docker и Deploy пропускаются (условия
ifограничивают их только веткойmain).
Когда нужен экстренный деплой (пропуск тестов):
- Нажмите «Run workflow» во вкладке Actions.
- Выберите
skip_tests: true. - Lint и typecheck всё равно запускаются (вы не можете их пропустить — я не настолько себе доверяю).
- Тесты пропускаются, build запускается, Docker собирается, деплой выполняется.
Это мой воркфлоу, который я использую уже два года. Он пережил миграции серверов, мажорные обновления Node.js, замену npm на pnpm и добавление 15 инструментов на этот сайт. Общее время от пуша до продакшена: в среднем 3 минуты 40 секунд. Самый медленный шаг — мультиплатформенная Docker-сборка, занимающая ~90 секунд. Всё остальное закэшировано до почти мгновенного.
Уроки двух лет итераций#
Закончу ошибками, которые я совершил, чтобы вам не пришлось.
Закрепляйте версии экшенов. uses: actions/checkout@v4 — нормально, но для продакшена рассмотрите uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (полный SHA). Скомпрометированный экшен может слить ваши секреты. Инцидент с tj-actions/changed-files в 2025 году доказал, что это не теория.
Не кэшируйте всё. Однажды я закэшировал node_modules напрямую (а не только хранилище pnpm) и потратил два часа на отладку фантомной ошибки сборки, вызванной устаревшими нативными биндингами. Кэшируйте хранилище пакетного менеджера, а не установленные модули.
Устанавливайте таймауты. Каждая джоба должна иметь timeout-minutes. По умолчанию — 360 минут (6 часов). Если ваш деплой зависнет из-за обрыва SSH-соединения, вы не хотите обнаружить это через шесть часов, когда исчерпаете месячный бюджет минут.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestИспользуйте concurrency с умом. Для PR cancel-in-progress: true всегда правильно — никому не нужен результат CI для коммита, который уже перезаписан force-push. Для продакшен-деплоев установите false. Вы не хотите, чтобы быстрый следующий коммит отменил деплой, который в процессе выкатки.
Тестируйте файл воркфлоу. Используйте act (https://github.com/nektos/act) для локального запуска воркфлоу. Он не поймает всё (секреты недоступны, и среда раннера отличается), но поймает синтаксические ошибки YAML и очевидные логические баги до пуша.
Следите за расходами на CI. Минуты GitHub Actions бесплатны для публичных репозиториев и дёшевы для приватных, но они накапливаются. Мультиплатформенные Docker-сборки потребляют вдвое больше минут (по одной на платформу). Матричные стратегии тестирования умножают время выполнения. Следите за страницей биллинга.
Лучший CI/CD-пайплайн — тот, которому вы доверяете. Доверие складывается из надёжности, наблюдаемости и постепенного улучшения. Начните с простого пайплайна lint-test-build. Добавьте Docker, когда понадобится воспроизводимость. Добавьте SSH-деплой, когда понадобится автоматизация. Добавьте уведомления, когда понадобится уверенность. Не стройте полный пайплайн в первый день — вы ошибётесь с абстракциями.