Перейти до вмісту
·29 хв читання

GitHub Actions CI/CD: Деплой без простою, який реально працює

Моє повне налаштування GitHub Actions: паралельні тестові джоби, кешування збірок Docker, SSH-деплой на VPS, zero-downtime з PM2 reload, управління секретами та патерни воркфлоу, які я вдосконалював два роки.

Поділитися:X / TwitterLinkedIn

Кожен проєкт, над яким я працював, врешті-решт доходить до тієї самої точки перелому: процес деплою стає занадто болючим для ручного виконання. Ви забуваєте запустити тести. Ви збираєте локально, але забуваєте оновити версію. Ви підключаєтеся по SSH до продакшену і розумієте, що попередня людина, яка деплоїла, залишила застарілий файл .env.

GitHub Actions вирішив це для мене два роки тому. Не ідеально з першого дня — перший воркфлоу, який я написав, був кошмаром на 200 рядків YAML, який половину часу падав по таймауту і нічого не кешував. Але ітерація за ітерацією я прийшов до чогось, що деплоїть цей сайт надійно, без простою, менш ніж за чотири хвилини.

Це той воркфлоу, пояснений секція за секцією. Не версія з документації. Версія, яка витримує зіткнення з продакшеном.

Розуміння будівельних блоків#

Перш ніж ми перейдемо до повного пайплайну, вам потрібна чітка ментальна модель того, як працює GitHub Actions. Якщо ви використовували Jenkins або CircleCI, забудьте більшість того, що знаєте. Концепції відображаються приблизно, але модель виконання достатньо відрізняється, щоб збити з пантелику.

Тригери: коли ваш воркфлоу запускається#

yaml
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 тестує, як буде виглядати код після мерджу. Це насправді те, що вам потрібно, але людей дивує, коли зелена гілка стає червоною після ребейзу.

Джоби, кроки та раннери#

yaml
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 на машині розробника вже правильний.

Змінні середовища проти секретів#

yaml
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-частину пайплайну. Мета: зловити кожну категорію помилок у найшвидший можливий час.

yaml
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) ви можете захотіти побачити всі провали одразу:

yaml
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 test

fail-fast: false дозволяє обом ногам матриці завершитися. Якщо Node 22 провалюється, але Node 20 проходить, ви бачите цю інформацію одразу, а не чекаєте перезапуску.

Кешування для швидкості#

Єдине найбільше покращення, яке ви можете зробити для швидкості CI — це кешування. Холодний pnpm install на середньому проєкті займає 30-45 секунд. З теплим кешем — 3-5 секунд. Помножте це на чотири паралельні джоби, і ви економите дві хвилини на кожному запуску.

Кеш сховища pnpm#

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "pnpm"

Цей однорядник кешує сховище pnpm (~/.local/share/pnpm/store). При потраплянні в кеш pnpm install --frozen-lockfile просто створює жорсткі посилання зі сховища замість завантаження. Це одне скорочує час встановлення на 80% при повторних запусках.

Якщо вам потрібно більше контролю — скажімо, ви хочете кешувати також на основі ОС — використовуйте actions/cache напряму:

yaml
- 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. Кешування цього між запусками означає інкрементальні збірки — перекомпілюються лише змінені сторінки та компоненти.

yaml
- 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 }}-

Ця трирівнева стратегія ключів означає:

  1. Точний збіг: ті самі залежності І ті самі вихідні файли. Повне потрапляння в кеш, збірка майже миттєва.
  2. Частковий збіг (залежності): залежності ті самі, але вихідники змінилися. Збірка перекомпілює лише змінені файли.
  3. Частковий збіг (лише ОС): залежності змінилися. Збірка використовує те, що може.

Реальні цифри з мого проєкту: холодна збірка займає ~55 секунд, кешована збірка — ~15 секунд. Це зменшення на 73%.

Кешування шарів Docker#

Docker-збірки — це де кешування стає справді впливовим. Повна Docker-збірка Next.js — встановлення залежностей ОС, копіювання вихідників, запуск pnpm install, запуск next build — займає 3-4 хвилини холодно. З кешуванням шарів — 30-60 секунд.

yaml
- 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=max

type=gha використовує вбудований бекенд кешу GitHub Actions. mode=max кешує всі шари, а не лише фінальні. Це критично для багатоетапних збірок, де проміжні шари (як pnpm install) є найдорожчими для перебудови.

Віддалений кеш Turborepo#

Якщо ви в монорепозиторії з Turborepo, віддалене кешування є трансформативним. Перша збірка завантажує результати завдань у кеш. Подальші збірки завантажують замість перерахунку.

yaml
- 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:

dockerfile
# Етап 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#

yaml
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 з трьох причин:

  1. Автентифікація безкоштовна. GITHUB_TOKEN автоматично доступний у кожному воркфлоу — не потрібно зберігати облікові дані Docker Hub.
  2. Близькість. Образи завантажуються з тієї ж інфраструктури, на якій працює ваш CI. Завантаження під час CI швидкі.
  3. Видимість. Образи пов'язані з вашим репозиторієм в UI GitHub. Ви бачите їх на вкладці Packages.

Мультиплатформні збірки#

yaml
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 автоматично завантажував правильну архітектуру.

Стратегія тегування#

yaml
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#

yaml
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 я переношу логіку в скрипт на сервері замість вбудовування в воркфлоу:

bash
#!/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-командою:

yaml
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 дає вам найпростіший шлях до нульового простою.

bash
# ecosystem.config.js вже має:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (не restart) робить поступовий перезапуск. Він запускає нових воркерів, чекає їхньої готовності, потім вбиває старих воркерів по одному. В жодний момент нуль воркерів не обслуговує трафік.

Прапор --update-env перезавантажує змінні середовища з конфігурації ecosystem. Без нього ваші старі змінні середовища зберігаються навіть після деплою, який змінив .env.

У вашому воркфлоу:

yaml
- 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:

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /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;
    }
}

Скрипт переключення:

bash
#!/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:

yaml
# 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 ви отримуєте поступове оновлення — один контейнер за раз, завжди підтримуючи ємність.

Деплой за допомогою:

bash
docker compose pull
docker compose up -d --remove-orphans

Docker Compose стежить за healthcheck і не направляє трафік на новий контейнер, поки він не пройде. Якщо healthcheck провалюється, failure_action: rollback автоматично повертається до попередньої версії. Це настільки близько до Kubernetes-стилю rolling deployments, наскільки можна дістатися на одному VPS.

Управління секретами#

Управління секретами — це одна з тих речей, яку легко зробити "майже правильно" і катастрофічно неправильно у решті граничних випадків.

GitHub Secrets: основи#

yaml
# Встановити через 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 може не маскувати його, тому що він може збігатися з невинними рядками. Тримайте секрети достатньо складними.

Секрети з областю видимості середовища#

yaml
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 діє як провайдер ідентичності, і ваш хмарний провайдер довіряє йому видавати короткострокові облікові дані на льоту:

yaml
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 та політики довіри ролі:

json
{
  "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 згенеруйте виділену пару ключів:

bash
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#

yaml
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#

Превью-середовища, які не очищуються, з'їдають диск і пам'ять. Додайте джобу очищення:

yaml
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 — Без помилок lint
  • typecheck — Без помилок типів
  • test — Всі тести пройдені
  • build — Проєкт збирається успішно

Без цього хтось обов'язково змерджить PR з провальними перевірками. Не зловмисно — вони побачать "2 з 4 перевірок пройдено" і припустять, що інші два ще виконуються. Заблокуйте це.

Також увімкніть "Require branches to be up to date before merging." Це примусово перезапускає CI після ребейзу на останній main. Це ловить випадок, коли два PR окремо проходять CI, але конфліктують при поєднанні.

Сповіщення#

Деплой, про який ніхто не знає — це деплой, якому ніхто не довіряє. Сповіщення замикають зворотний зв'язок.

Slack Webhook#

yaml
- 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 репозиторію та вмикає бейджі статусу:

yaml
- 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 (занадто шумний), а через цільовий вебхук:

yaml
- 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" з посиланням на запуск привертає увагу.

Повний файл воркфлоу#

Ось все з'єднане в один, готовий до продакшену воркфлоу. Це дуже близько до того, що насправді деплоїть цей сайт.

yaml
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 }}"
            }' || true

Akışın adım adım incelemesi#

main'e push yaptığımda:

  1. Lint, Type Check ve Test aynı anda başlar. Üç runner, üç paralel джоба. Herhangi biri provaliться, пайплайн durur.
  2. Build запускається лише якщо всі три пройдені. Він підтверджує, що застосунок компілюється та виробляє робочий результат.
  3. Docker збирає продакшн-образ і пушить його на ghcr.io. Мультиплатформний, з кешуванням шарів.
  4. Deploy підключається по SSH до VPS, завантажує новий образ, запускає новий контейнер, перевіряє його здоров'я, переключає Nginx та очищує.
  5. Сповіщення спрацьовують незалежно від результату. Slack отримує повідомлення. GitHub Deployments оновлюються. Якщо провал — надсилається алерт email.

Коли я відкриваю PR:

  1. Lint, Type Check і Test запускаються. Ті самі перевірки якості.
  2. Build запускається для перевірки, що проєкт компілюється.
  3. Docker і Deploy пропускаються (умови if обмежують їх лише гілкою main).

Коли потрібен екстрений деплой (пропуск тестів):

  1. Натискаю "Run workflow" у вкладці Actions.
  2. Обираю skip_tests: true.
  3. Lint і typecheck все одно запускаються (їх не можна пропустити — я не довіряю собі настільки).
  4. Тести пропускаються, 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-з'єднання, ви не хочете дізнатися про це через шість годин, коли ви витратили місячний бюджет хвилин.

yaml
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-деплой, коли вам потрібна автоматизація. Додайте сповіщення, коли вам потрібна впевненість. Не будуйте повний пайплайн з першого дня — ви помилитесь з абстракціями.

Будуйте пайплайн, який вам потрібен сьогодні, і нехай він росте разом з вашим проєктом.

Схожі записи