Przejdź do treści
·21 min czytania

GitHub Actions CI/CD: Zero-downtime deploymenty, które naprawdę działają

Moja kompletna konfiguracja GitHub Actions: równoległe joby testowe, cachowanie buildów Docker, deployment SSH na VPS, zero-downtime z PM2 reload, zarządzanie sekretami i wzorce workflow, które szlifowałem przez dwa lata.

Udostępnij:X / TwitterLinkedIn

Każdy projekt, nad którym pracowałem, w końcu dochodzi do tego samego punktu zwrotnego: proces deployu staje się zbyt bolesny, żeby robić go ręcznie. Zapominasz uruchomić testy. Budujesz lokalnie, ale zapominasz podbić wersję. SSHujesz się na produkcję i odkrywasz, że ostatnia osoba, która deployowała, zostawiła nieaktualny plik .env.

GitHub Actions rozwiązał to dla mnie dwa lata temu. Nie idealnie od pierwszego dnia — pierwszy workflow, który napisałem, to był 200-liniowy YAML-owy koszmar, który wywalał się po timeout w połowie przypadków i nic nie cachował. Ale iteracja po iteracji doszedłem do czegoś, co deployuje tę stronę niezawodnie, z zero downtime, w mniej niż cztery minuty.

To jest ten workflow, wyjaśniony sekcja po sekcji. Nie wersja z dokumentacji. Wersja, która przetrwała kontakt z produkcją.

Zrozumienie elementów składowych#

Zanim przejdziemy do pełnego pipeline'u, musisz mieć jasny model mentalny tego, jak GitHub Actions działa. Jeśli używałeś Jenkinsa lub CircleCI, zapomnij większość tego, co wiesz. Koncepcje mapują się luźno, ale model wykonania jest na tyle inny, że może cię potknąć.

Triggery: Kiedy twój workflow się uruchamia#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Every Monday at 6 AM UTC
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

Cztery triggery, każdy służący innemu celowi:

  • push na main to twój trigger deployu produkcyjnego. Kod zmergowany? Wysyłaj.
  • pull_request uruchamia sprawdzenia CI na każdym PR. Tu żyją lint, sprawdzenia typów i testy.
  • schedule to cron dla twojego repozytorium. Używam go do cotygodniowych audytów zależności i czyszczenia nieaktualnych cache'ów.
  • workflow_dispatch daje ci ręczny przycisk "Deploy" w interfejsie GitHub z parametrami wejściowymi. Nieocenione, gdy musisz deployować staging bez zmiany kodu — może zaktualizowałeś zmienną środowiskową lub musisz ponownie pobrać bazowy obraz Docker.

Jedna rzecz, która gryzie ludzi: pull_request uruchamia się na merge commit, nie na HEAD brancha PR. To znaczy, że twoje CI testuje, jak kod będzie wyglądał po merge. To właściwie to, czego chcesz, ale zaskakuje ludzi, gdy zielony branch staje się czerwony po rebase.

Joby, kroki i runnery#

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

Joby domyślnie działają równolegle. Każdy job dostaje świeżą maszynę wirtualną ("runner"). ubuntu-latest daje ci dość wydajną maszynę — 4 vCPU, 16 GB RAM według stanu na 2026. To jest darmowe dla publicznych repozytoriów, 2000 minut/miesiąc dla prywatnych.

Kroki działają sekwencyjnie w obrębie joba. Każdy krok uses: wciąga akcję wielokrotnego użytku z marketplace. Każdy krok run: wykonuje komendę powłoki.

Flaga --frozen-lockfile jest kluczowa. Bez niej pnpm install może zaktualizować twój lockfile podczas CI, co oznacza, że nie testujesz tych samych zależności, które developer commitnął. Widziałem, jak to powodowało fantomowe niepowodzenia testów, które znikały lokalnie, bo lockfile na maszynie developera był już prawidłowy.

Zmienne środowiskowe vs sekrety#

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"

Zmienne środowiskowe ustawione z env: na poziomie workflow to zwykły tekst, widoczny w logach. Używaj ich do niewrażliwej konfiguracji: NODE_ENV, flagi telemetrii, przełączniki funkcji.

Sekrety (${{ secrets.X }}) są zaszyfrowane w spoczynku, maskowane w logach i dostępne tylko dla workflow w tym samym repozytorium. Ustawia się je w Settings > Secrets and variables > Actions.

Linia environment: production jest znacząca. GitHub Environments pozwalają ograniczyć sekrety do określonych celów deploymentu. Twój klucz SSH stagingu i klucz SSH produkcji mogą oba nazywać się SSH_PRIVATE_KEY, ale trzymać różne wartości w zależności od tego, na które środowisko celuje job. To też odblokowuje wymaganych recenzentów — możesz zabramkować deploye produkcyjne za ręcznym zatwierdzeniem.

Pełny pipeline CI#

Oto jak strukturyzuję połowę CI pipeline'u. Cel: złapać każdą kategorię błędu w najkrótszym możliwym czasie.

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

Dlaczego ta struktura#

Lint, typecheck i test działają równolegle. Nie mają między sobą zależności. Błąd typów nie blokuje linta, a nieudany test nie musi czekać na sprawdzenie typów. Na typowym uruchomieniu wszystkie trzy kończą się w 30-60 sekund, działając jednocześnie.

Build czeka na wszystkie trzy. Linia needs: [lint, typecheck, test] oznacza, że job build startuje tylko wtedy, gdy lint, typecheck I test przejdą pomyślnie. Nie ma sensu budować projektu, który ma błędy lintowania lub typów.

concurrency z cancel-in-progress: true to ogromna oszczędność czasu. Jeśli pushasz dwa commity szybko po sobie, pierwsze uruchomienie CI jest anulowane. Bez tego miałbyś nieaktualne uruchomienia zużywające twój budżet minut i zaśmiecające interfejs sprawdzeń.

Upload coverage z if: always() oznacza, że dostajesz raport pokrycia nawet gdy testy zawiodą. To jest użyteczne do debugowania — widzisz, które testy zawiodły i co pokrywały.

Fail-Fast vs. pozwól wszystkim działać#

Domyślnie, jeśli jeden job w macierzy zawodzi, GitHub anuluje pozostałe. Dla CI właściwie chcę tego zachowania — jeśli lint zawodzi, nie obchodzą mnie wyniki testów. Najpierw napraw lint.

Ale dla macierzy testowych (powiedzmy, testowanie na Node 20 i Node 22), możesz chcieć zobaczyć wszystkie niepowodzenia naraz:

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 pozwala obu nogom macierzy się zakończyć. Jeśli Node 22 zawodzi, ale Node 20 przechodzi, widzisz tę informację natychmiast zamiast musieć ponownie uruchamiać.

Cachowanie dla szybkości#

Pojedyncze największe ulepszenie, jakie możesz wprowadzić do szybkości CI, to cachowanie. Zimny pnpm install na średnim projekcie zajmuje 30-45 sekund. Z ciepłym cache'em zajmuje 3-5 sekund. Pomnóż to przez cztery równoległe joby i oszczędzasz dwie minuty na każdym uruchomieniu.

Cache store pnpm#

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

Ten jednolinijkowiec cachuje store pnpm (~/.local/share/pnpm/store). Przy trafieniu w cache pnpm install --frozen-lockfile po prostu tworzy hardlinki ze store zamiast pobierać. To samo tnie czas instalacji o 80% przy powtórnych uruchomieniach.

Jeśli potrzebujesz więcej kontroli — powiedzmy, chcesz cachować też na podstawie OS — użyj actions/cache bezpośrednio:

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

Fallback restore-keys jest ważny. Jeśli pnpm-lock.yaml się zmieni (nowa zależność), dokładny klucz nie trafi, ale dopasowanie prefiksowe nadal przywróci większość cachowanych pakietów. Pobrane zostaną tylko różnice.

Cache buildu Next.js#

Next.js ma własny cache buildu w .next/cache. Cachowanie go między uruchomieniami oznacza inkrementalne buildy — tylko zmienione strony i komponenty są rekompilowane.

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

Ta trzystopniowa strategia kluczy oznacza:

  1. Dokładne trafienie: te same zależności I te same pliki źródłowe. Pełne trafienie w cache, build jest niemal natychmiastowy.
  2. Częściowe trafienie (zależności): zależności te same, ale źródło się zmieniło. Build rekompiluje tylko zmienione pliki.
  3. Częściowe trafienie (tylko OS): zależności się zmieniły. Build wykorzystuje co może.

Realne liczby z mojego projektu: zimny build zajmuje ~55 sekund, build z cache'em zajmuje ~15 sekund. To 73% redukcji.

Cachowanie warstw Dockera#

Buildy Dockera to miejsce, gdzie cachowanie naprawdę robi różnicę. Pełny build Dockera Next.js — instalacja zależności OS, kopiowanie źródeł, uruchomienie pnpm install, uruchomienie next build — zajmuje 3-4 minuty na zimno. Z cachowaniem warstw to 30-60 sekund.

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 używa wbudowanego backendu cache GitHub Actions. mode=max cachuje wszystkie warstwy, nie tylko finalne. To jest krytyczne dla multi-stage buildów, gdzie pośrednie warstwy (jak pnpm install) są najdroższe do przebudowania.

Zdalny cache Turborepo#

Jeśli jesteś w monorepo z Turborepo, zdalny cache jest transformatywny. Pierwszy build wgrywa outputy tasków do cache. Kolejne buildy pobierają zamiast przeliczać.

yaml
- run: pnpm turbo build --remote-only
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

Widziałem, jak czasy CI monorepo spadały z 8 minut do 90 sekund ze zdalnym cache Turbo. Haczyk: wymaga konta Vercel lub self-hosted serwera Turbo. Dla repozytoriów z jedną aplikacją to overkill.

Build i push Dockera#

Jeśli deployujesz na VPS (lub dowolny serwer), Docker daje ci reprodukowalne buildy. Ten sam obraz, który działa w CI, to ten sam obraz, który działa na produkcji. Koniec z "u mnie działa", bo maszyna jest obrazem.

Multi-Stage Dockerfile#

Zanim przejdziemy do workflow, oto Dockerfile, którego używam do Next.js:

dockerfile
# Stage 1: Dependencies
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
 
# Stage 2: Build
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
 
# Stage 3: Production
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"]

Trzy etapy, jasne rozdzielenie. Finalny obraz to ~150MB zamiast ~1,2GB, które byś dostał kopiując wszystko. Tylko artefakty produkcyjne trafiają do etapu runner.

Workflow 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

Pozwól, że rozpakuję ważne decyzje.

GitHub Container Registry (ghcr.io)#

Używam ghcr.io zamiast Docker Hub z trzech powodów:

  1. Autentykacja jest darmowa. GITHUB_TOKEN jest automatycznie dostępny w każdym workflow — nie trzeba przechowywać poświadczeń Docker Hub.
  2. Bliskość. Obrazy są pobierane z tej samej infrastruktury, na której działa twoje CI. Pobrania podczas CI są szybkie.
  3. Widoczność. Obrazy są linkowane do twojego repozytorium w interfejsie GitHub. Widzisz je w zakładce Packages.

Buildy multi-platform#

yaml
platforms: linux/amd64,linux/arm64

Ta linia dodaje może 90 sekund do twojego buildu, ale jest tego warta. Obrazy ARM64 działają natywnie na:

  • Mackach z Apple Silicon (M1/M2/M3/M4) podczas lokalnego developmentu z Docker Desktop
  • Instancjach AWS Graviton (20-40% tańszych niż odpowiedniki x86)
  • Darmowym tierze ARM Oracle Cloud

Bez tego twoi developerzy na Mackach z serii M uruchamiają obrazy x86 przez emulację Rosetta. Działa, ale jest zauważalnie wolniejsze i okazjonalnie ujawnia dziwne bugi specyficzne dla architektury.

QEMU zapewnia warstwę cross-kompilacji. Buildx orkiestruje build multi-arch i pushuje manifest list, więc Docker automatycznie pobiera właściwą architekturę.

Strategia tagowania#

yaml
tags: |
  type=sha,prefix=
  type=ref,event=branch
  type=raw,value=latest,enable={{is_default_branch}}

Każdy obraz dostaje trzy tagi:

  • abc1234 (SHA commita): Niezmienny. Zawsze możesz deployować dokładny commit.
  • main (nazwa brancha): Zmienny. Wskazuje na najnowszy build z tego brancha.
  • latest: Zmienny. Ustawiany tylko na domyślnym branchu. To jest to, co twój serwer pobiera.

Nigdy nie deployuj latest na produkcji bez jednoczesnego zapisania SHA gdzieś. Gdy coś się zepsuje, musisz wiedzieć który latest. Przechowuję deployowane SHA w pliku na serwerze, który endpoint health odczytuje.

Deployment SSH na VPS#

Tu wszystko się łączy. CI przechodzi, obraz Dockera jest zbudowany i wypushowany, teraz musimy powiedzieć serwerowi, żeby pobrał nowy obraz i zrestartował.

Akcja SSH#

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 ==="
 
          # Pull the latest image
          docker pull "$IMAGE"
 
          # Stop and remove old container
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # Start new container
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # Wait for 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
 
          # Record deployed SHA
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # Prune old images
          docker image prune -af --filter "until=168h"
 
          echo "=== Deploy complete ==="

Alternatywa ze skryptem deployu#

Dla czegokolwiek poza prostym pull-and-restart przenoszę logikę do skryptu na serwerze zamiast wstawiać ją inline w workflow:

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..."
 
# Login to GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# Pull with retry
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 function
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
}
 
# Start new container on alternate port
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# Verify new container is healthy
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..."
 
# Switch Nginx upstream
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
 
# Stop old container
docker stop akousa-app || true
docker rm akousa-app || true
 
# Rename new container
docker rename akousa-app-new akousa-app
 
log "Deployment complete."

Workflow wtedy staje się jedną komendą SSH:

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

To jest lepsze, bo: (1) logika deployu jest wersjonowana na serwerze, (2) możesz ją uruchomić ręcznie przez SSH do debugowania, i (3) nie musisz escapować YAML wewnątrz YAML wewnątrz basha.

Strategie zero-downtime#

"Zero downtime" brzmi jak marketing, ale ma precyzyjne znaczenie: żadne żądanie nie dostaje connection refused ani 502 podczas deploymentu. Oto trzy realne podejścia, od najprostszego do najbardziej solidnego.

Strategia 1: PM2 Cluster Mode Reload#

Jeśli uruchamiasz Node.js bezpośrednio (nie w Dockerze), tryb cluster PM2 daje ci najłatwiejszą ścieżkę do zero-downtime.

bash
# ecosystem.config.js already has:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (nie restart) robi rolling restart. Uruchamia nowe workery, czeka aż będą gotowe, potem zabija stare workery jeden po jednym. W żadnym momencie zero workerów nie obsługuje ruchu.

Flaga --update-env przeładowuje zmienne środowiskowe z konfiguracji ecosystem. Bez niej twoje stare env przetrwa nawet po deployu, który zmienił .env.

W twoim workflow:

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

Tego używam dla tej strony. To proste, niezawodne i downtime jest dosłownie zero — testowałem to z generatorem obciążenia robiącym 100 req/s podczas deployów. Ani jeden 5xx.

Strategia 2: Blue/Green z Nginx Upstream#

Dla deploymentów Dockera blue/green daje ci czyste rozdzielenie między starą i nową wersją.

Koncepcja: uruchom stary kontener ("blue") na porcie 3000 i nowy kontener ("green") na porcie 3001. Nginx wskazuje na blue. Uruchamiasz green, weryfikujesz że jest zdrowy, przełączasz Nginx na green, potem zatrzymujesz blue.

Konfiguracja Nginx upstream:

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

Skrypt przełączania:

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"
 
# Start new container on the alternate 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"
 
# Wait for health
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
 
# Switch 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
 
# Stop old container
sleep 5  # Let in-flight requests complete
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Switched from :$OLD_PORT to :$NEW_PORT"

5-sekundowy sleep po przeładowaniu Nginx to nie lenistwo — to czas łaski. Przeładowanie Nginx jest graceful (istniejące połączenia są utrzymywane), ale niektóre połączenia long-polling lub odpowiedzi streamingowe potrzebują czasu na zakończenie.

Strategia 3: Docker Compose z Health Checks#

Dla bardziej ustrukturyzowanego podejścia Docker Compose może zarządzać zamianą 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"

Linia order: start-first to klucz. Oznacza "uruchom nowy kontener przed zatrzymaniem starego". W połączeniu z parallelism: 1 dostajesz rolling update — jeden kontener naraz, zawsze utrzymując pojemność.

Deploy z:

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

Docker Compose obserwuje healthcheck i nie kieruje ruchu do nowego kontenera, dopóki nie przejdzie. Jeśli healthcheck zawiedzie, failure_action: rollback automatycznie wraca do poprzedniej wersji. To jest najbliżej deploymentów rolling w stylu Kubernetes, jak możesz dojść na jednym VPS.

Zarządzanie sekretami#

Zarządzanie sekretami to jedna z tych rzeczy, które łatwo zrobić "prawie dobrze" i katastrofalnie źle w pozostałych przypadkach brzegowych.

Sekrety GitHub: Podstawy#

yaml
# Set via GitHub UI: Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # The value is masked in logs
      echo "Connecting to database..."
      # This would print "Connecting to ***" in the logs
      echo "Connecting to $DB_URL"

GitHub automatycznie redukuje wartości sekretów z logów. Jeśli twój sekret to p@ssw0rd123 i jakikolwiek krok wydrukuje ten string, logi pokażą ***. To działa dobrze, z jednym zastrzeżeniem: jeśli twój sekret jest krótki (jak 4-cyfrowy PIN), GitHub może go nie zamaskować, bo mógłby pasować do niewinnych stringów. Trzymaj sekrety rozsądnie złożone.

OIDC: Koniec ze statycznymi poświadczeniami#

Statyczne poświadczenia (klucze dostępu AWS, pliki JSON kont serwisowych GCP) przechowywane w GitHub Secrets to zobowiązanie. Nie wygasają, nie mogą być ograniczone do konkretnego uruchomienia workflow, a jeśli wyciekną, musisz je ręcznie rotować.

OIDC (OpenID Connect) to rozwiązuje. GitHub Actions działa jako dostawca tożsamości, a twój dostawca chmury ufa mu w wystawianiu krótkotrwałych poświadczeń na bieżąco:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Required for 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

Bez klucza dostępu. Bez klucza sekretnego. Akcja configure-aws-credentials żąda tymczasowego tokena od AWS STS używając tokena OIDC GitHub. Token jest ograniczony do konkretnego repozytorium, brancha i środowiska. Wygasa po uruchomieniu workflow.

GCP ma równoważną konfigurację z Workload Identity Federation. Azure ma federated credentials. Jeśli twoja chmura obsługuje OIDC, używaj go. Nie ma powodu przechowywać statycznych poświadczeń chmurowych w 2026 roku.

Klucze SSH do deployu#

Dla deploymentów VPS przez SSH wygeneruj dedykowaną parę kluczy:

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

Dodaj klucz publiczny do ~/.ssh/authorized_keys serwera z ograniczeniami:

restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy

Prefiks restrict wyłącza port forwarding, agent forwarding, alokację PTY i X11 forwarding. Prefiks command= oznacza, że ten klucz może tylko wykonać skrypt deployu. Nawet jeśli klucz prywatny zostanie skompromitowany, atakujący może uruchomić twój skrypt deployu i nic więcej.

Dodaj klucz prywatny do GitHub Secrets jako SSH_PRIVATE_KEY. To jedyne statyczne poświadczenie, które akceptuję — klucze SSH z wymuszonymi komendami mają bardzo ograniczony blast radius.

Workflow PR: Preview Deployments#

Każdy PR zasługuje na środowisko preview. Łapie wizualne bugi, które testy jednostkowe pomijają, pozwala designerom przeglądać bez checkoutowania kodu i dramatycznie ułatwia życie QA.

Deploy preview przy otwarciu 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 }}\`_`;
 
            // Find existing comment
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
 
            const botComment = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Deployment')
            );
 
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

Obliczanie portu (4000 + PR_NUM) to pragmatyczny hack. PR #42 dostaje port 4042. Dopóki nie masz więcej niż kilkaset otwartych PR, nie ma kolizji. Wildcard config Nginx routuje pr-*.preview.akousa.net do właściwego portu.

Czyszczenie przy zamknięciu PR#

Środowiska preview, które nie są czyszczone, zjadają dysk i pamięć. Dodaj job czyszczenia:

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',
              });
            }

Wymagane sprawdzenia statusu#

W ustawieniach repozytorium (Settings > Branches > Branch protection rules), wymagaj tych sprawdzeń przed merge'em:

  • lint — Brak błędów lintowania
  • typecheck — Brak błędów typów
  • test — Wszystkie testy przechodzą
  • build — Projekt się buduje pomyślnie

Bez tego ktoś będzie mergował PR z nieprzechodzącymi sprawdzeniami. Nie złośliwie — zobaczy "2 z 4 sprawdzeń przeszło" i założy, że pozostałe dwa jeszcze działają. Zablokuj to.

Włącz też "Require branches to be up to date before merging." To wymusza ponowne uruchomienie CI po rebase na najnowszy main. Łapie przypadek, gdy dwa PR indywidualnie przechodzą CI, ale kolidują po połączeniu.

Powiadomienia#

Deployment, o którym nikt nie wie, to deployment, któremu nikt nie ufa. Powiadomienia zamykają pętlę zwrotną.

Webhook Slack#

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() jest krytyczne. Bez niego krok powiadomienia jest pomijany, gdy deploy zawodzi — a to jest dokładnie wtedy, gdy najbardziej go potrzebujesz.

Lekcje z dwóch lat iteracji#

Zakończę błędami, które popełniłem, żebyś nie musiał.

Pinuj wersje akcji. uses: actions/checkout@v4 jest w porządku, ale na produkcji rozważ uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (pełne SHA). Skompromitowana akcja mogłaby wyekstrahować twoje sekrety. Incydent tj-actions/changed-files w 2025 udowodnił, że to nie jest teoretyczne.

Nie cachuj wszystkiego. Kiedyś cachowałem node_modules bezpośrednio (nie tylko store pnpm) i spędziłem dwie godziny debugując fantomowy błąd buildu spowodowany nieaktualnymi natywnymi bindingami. Cachuj store menedżera pakietów, nie zainstalowane moduły.

Ustawiaj timeouty. Każdy job powinien mieć timeout-minutes. Domyślne to 360 minut (6 godzin). Jeśli twój deploy się zawiesi, bo połączenie SSH padło, nie chcesz odkryć tego sześć godzin później, gdy spaliłeś swój miesięczny budżet minut.

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

Używaj concurrency mądrze. Dla PR, cancel-in-progress: true jest zawsze słuszne — nikogo nie obchodzi wynik CI commita, który już został force-pushnięty. Dla deployów produkcyjnych ustaw na false. Nie chcesz, żeby szybki następny commit anulował deploy, który jest w trakcie rollout.

Testuj swój plik workflow. Użyj act (https://github.com/nektos/act) do uruchamiania workflow lokalnie. Nie złapie wszystkiego (sekrety nie są dostępne, a środowisko runnera się różni), ale łapie błędy składni YAML i oczywiste bugi logiczne zanim pushasz.

Monitoruj koszty CI. Minuty GitHub Actions są darmowe dla publicznych repozytoriów i tanie dla prywatnych, ale się sumują. Multi-platform Docker buildy to 2x minut (jeden na platformę). Macierzowe strategie testowe mnożą twój czas wykonania. Obserwuj stronę billingową.

Najlepszy pipeline CI/CD to ten, któremu ufasz. Zaufanie pochodzi z niezawodności, obserwowalności i inkrementalnego ulepszania. Zacznij od prostego pipeline'u lint-test-build. Dodaj Dockera, gdy potrzebujesz reprodukowalności. Dodaj deployment SSH, gdy potrzebujesz automatyzacji. Dodaj powiadomienia, gdy potrzebujesz pewności. Nie buduj pełnego pipeline'u w pierwszy dzień — źle dobierzesz abstrakcje.

Buduj pipeline, którego potrzebujesz dzisiaj, i pozwól mu rosnąć z twoim projektem.

Powiązane wpisy