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. Тут живуть lint, перевірка типів та тести.schedule— це cron для вашого репозиторію. Я використовую його для щотижневих аудитів залежностей і очищення застарілого кешу.workflow_dispatchдає вам ручну кнопку "Деплой" в UI GitHub з вхідними параметрами. Безцінно, коли вам потрібно задеплоїти staging без зміни коду — можливо, ви оновили змінну середовища або вам потрібно перезавантажити базовий Docker-образ.
Одна річ, яка кусає людей: pull_request запускається проти merge-коміту, а не 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 ГБ RAM станом на 2026 рік. Це безкоштовно для публічних репозиторіїв, 2000 хвилин/місяць для приватних.
Кроки виконуються послідовно всередині джоби. Кожен крок uses: підтягує багаторазову дію з маркетплейсу. Кожен крок run: виконує команду оболонки.
Прапор --frozen-lockfile є критичним. Без нього pnpm install може оновити ваш lockfile під час CI, що означає, що ви не тестуєте ті ж залежності, які розробник закомітив. Я бачив, як це спричиняє фантомні провали тестів, які зникають локально, тому що lockfile на машині розробника вже правильний.
Змінні середовища проти секретів#
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-ключ для staging і ваш SSH-ключ для production можуть обидва називатися 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, перевірка типів і тести запускаються паралельно. Вони не мають залежностей один від одного. Помилка типів не блокує lint від запуску, і провалений тест не потребує очікування перевірки типів. У типовому запуску всі три завершуються за 30-60 секунд, працюючи одночасно.
Build чекає на всі три. Рядок needs: [lint, typecheck, test] означає, що джоба build починається лише якщо lint, typecheck І test успішно пройдені. Немає сенсу збирати проєкт, який має помилки lint або помилки типів.
concurrency з cancel-in-progress: true — це величезна економія часу. Якщо ви пушите два коміти поспіль, перший CI-запуск скасовується. Без цього у вас будуть застарілі запуски, що витрачають ваш бюджет хвилин і засмічують UI перевірок.
Завантаження покриття з if: always() означає, що ви отримуєте звіт про покриття навіть коли тести провалюються. Це корисно для відладки — ви можете бачити, які тести провалилися і що вони покривали.
Fail-Fast проти запуску всіх#
За замовчуванням, якщо одна джоба в матриці провалюється, GitHub скасовує решту. Для CI мені насправді потрібна така поведінка — якщо lint провалився, мене не цікавлять результати тестів. Спершу виправте lint.
Але для матриць тестування (скажімо, тестування на 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 або самостійно розміщений сервер Turbo. Для репозиторіїв з одним застосунком це надлишково.
Docker Build and Push#
Якщо ви деплоїте на 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.
Воркфлоу Build-and-Push#
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 швидкі.
- Видимість. Образи пов'язані з вашим репозиторієм в UI 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 читає.
SSH-деплой на VPS#
Ось де все з'єднується. CI пройшов, Docker-образ зібраний і запушений, тепер нам потрібно сказати серверу завантажити новий образ і перезапуститися.
SSH Action#
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 "=== Deploying $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"
# Очікування health check
echo "Waiting for health check..."
for i in $(seq 1 30); do
if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
echo "Health check passed on attempt $i"
break
fi
if [ "$i" -eq 30 ]; then
echo "Health check failed after 30 attempts"
exit 1
fi
sleep 2
done
# Записати SHA деплою
echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
# Очистити старі образи
docker image prune -af --filter "until=168h"
echo "=== Deploy complete ==="Альтернатива — скрипт деплою#
Для будь-чого складнішого за просте 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 "Starting deployment..."
# Логін в GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
# Pull з повтором
for attempt in 1 2 3; do
if docker pull "$IMAGE"; then
log "Image pulled successfully on attempt $attempt"
break
fi
if [ "$attempt" -eq 3 ]; then
log "ERROR: Failed to pull image after 3 attempts"
exit 1
fi
log "Pull attempt $attempt failed, retrying in 5s..."
sleep 5
done
# Функція health check
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 "ERROR: New container failed health check. Rolling back."
docker stop akousa-app-new || true
docker rm akousa-app-new || true
exit 1
fi
log "New container healthy. Switching traffic..."
# Переключити 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 "Deployment complete."Воркфлоу тоді стає єдиною SSH-командою:
script: |
cd /var/www/akousa.net && ./deploy.shЦе краще, тому що: (1) логіка деплою контролюється версіями на сервері, (2) ви можете запустити її вручну через SSH для відладки, і (3) вам не потрібно екранувати YAML всередині YAML всередині bash.
Стратегії без простою#
"Нульовий простій" звучить як маркетинговий жаргон, але має точне значення: жоден запит не отримує відмову в з'єднанні або 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 перезавантажує змінні середовища з конфігурації ecosystem. Без нього ваші старі змінні середовища зберігаються навіть після деплою, який змінив .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.
Конфігурація upstream Nginx:
# /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 "Current: $OLD_PORT -> New: $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 container healthy on port $NEW_PORT"
break
fi
[ "$i" -eq 30 ] && { echo "Health check failed"; 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 "Switched from :$OLD_PORT to :$NEW_PORT"5-секундний sleep після перезавантаження Nginx — це не лінь, це час на завершення. Перезавантаження Nginx є graceful (існуючі з'єднання зберігаються), але деяким long-polling з'єднанням або потоковим відповідям потрібен час для завершення.
Стратегія 3: Docker Compose з Health Checks#
Для більш структурованого підходу 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-стилю rolling deployments, наскільки можна дістатися на одному VPS.
Управління секретами#
Управління секретами — це одна з тих речей, яку легко зробити "майже правильно" і катастрофічно неправильно у решті граничних випадків.
GitHub Secrets: основи#
# Встановити через GitHub UI: Settings > Secrets and variables > Actions
steps:
- name: Use a secret
env:
DB_URL: ${{ secrets.DATABASE_URL }}
run: |
# Значення маскується в логах
echo "Connecting to database..."
# Це надрукує "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 на джобі визначає, який набір секретів ін'єктується.
Продакшн-середовища повинні мати увімкнені обов'язкові рецензенти. Це означає, що push до main тригерить воркфлоу, CI запускається автоматично, але джоба деплою ставиться на паузу і чекає, поки хтось натисне "Approve" в UI 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 }}\`_`;
// Знайти існуючий коментар
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, колізій не буде. Wildcard-конфігурація Nginx направляє 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 "Preview for PR #${PR_NUM} cleaned up."
- 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— Без помилок linttypecheck— Без помилок типів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. Це дає вам історію деплоїв в UI репозиторію та вмикає бейджі статусу:
- 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: Збірка та push образу (лише гілка 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 "=== Deploy $SHA started at $(date) ==="
# Завантажити новий образ
docker pull "$IMAGE"
# Запустити новий контейнер на альтернативному порті
docker run -d \
--name akousa-app-new \
--env-file "$APP_DIR/.env.production" \
-p 3001:3000 \
"$IMAGE"
# Health check
echo "Running health check..."
for i in $(seq 1 30); do
if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
echo "Health check passed (attempt $i)"
break
fi
if [ "$i" -eq 30 ]; then
echo "ERROR: Health check failed"
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 "=== Deploy complete at $(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 }}"
}' || trueAkışın adım adım incelemesi#
main'e push yaptığımda:
- Lint, Type Check ve Test aynı anda başlar. Üç runner, üç paralel джоба. Herhangi biri provaliться, пайплайн durur.
- 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 інструментів на цей сайт. Загальний час від push до продакшену: 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-pushed. Для продакшн-деплоїв встановіть false. Ви не хочете, щоб швидкий наступний коміт скасував деплой, який посеред розгортання.
Тестуйте ваш файл воркфлоу. Використовуйте act (https://github.com/nektos/act) для локального запуску воркфлоу. Він не зловить все (секрети недоступні, і середовище раннера відрізняється), але він ловить помилки синтаксису YAML та очевидні логічні баги до того, як ви запушите.
Моніторте ваші витрати на CI. Хвилини GitHub Actions безкоштовні для публічних репозиторіїв і дешеві для приватних, але вони накопичуються. Мультиплатформні Docker-збірки — це 2x хвилин (одна на платформу). Матричні стратегії тестування множать ваш час виконання. Стежте за сторінкою білінгу.
Найкращий CI/CD-пайплайн — це той, якому ви довіряєте. Довіра приходить від надійності, спостережуваності та поступового вдосконалення. Почніть з простого пайплайну lint-test-build. Додайте Docker, коли вам потрібна відтворюваність. Додайте SSH-деплой, коли вам потрібна автоматизація. Додайте сповіщення, коли вам потрібна впевненість. Не будуйте повний пайплайн з першого дня — ви помилитесь з абстракціями.
Будуйте пайплайн, який вам потрібен сьогодні, і нехай він росте разом з вашим проєктом.