Přeskočit na obsah
·29 min čtení

GitHub Actions CI/CD: Zero-downtime deploymenty, které skutečně fungují

Můj kompletní setup GitHub Actions: paralelní test joby, Docker build caching, SSH deployment na VPS, zero-downtime s PM2 reload, správa secrets a workflow patterny vyladěné za dva roky.

Sdílet:X / TwitterLinkedIn

Každý projekt, na kterém jsem pracoval, nakonec dospěje ke stejnému bodu zlomu: proces nasazení se stane příliš bolestivým na to, aby se dělal ručně. Zapomenete spustit testy. Buildíte lokálně, ale zapomenete zvýšit verzi. Připojíte se přes SSH na produkci a zjistíte, že předchozí osoba, která nasazovala, tam nechala zastaralý .env soubor.

GitHub Actions mi to vyřešil před dvěma lety. Ne dokonale hned první den — první workflow, který jsem napsal, byl 200řádkový YAML horor, který v polovině případů vyexpiroval a nic necachoval. Ale iterace po iteraci jsem dospěl k něčemu, co nasazuje tento web spolehlivě, s nulovým výpadkem, za méně než čtyři minuty.

Toto je ten workflow, vysvětlený sekci po sekci. Není to verze z dokumentace. Je to verze, která přežije kontakt s produkcí.

Porozumění základním stavebním blokům#

Než se pustíme do celé pipeline, potřebujete jasný mentální model toho, jak GitHub Actions funguje. Pokud jste používali Jenkins nebo CircleCI, zapomeňte většinu toho, co víte. Koncepty se volně mapují, ale exekuční model je natolik odlišný, že vás může zmást.

Triggery: Kdy se váš workflow spustí#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Každé pondělí v 6:00 UTC
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

Čtyři triggery, každý slouží jinému účelu:

  • push do main je váš trigger pro produkční nasazení. Kód mergnutý? Jede se.
  • pull_request spouští vaše CI kontroly na každém PR. Tady žijí lint, typové kontroly a testy.
  • schedule je cron pro váš repozitář. Používám ho pro týdenní audity závislostí a čištění zastaralé cache.
  • workflow_dispatch vám dává manuální tlačítko "Deploy" v GitHub UI se vstupními parametry. Neocenitelné, když potřebujete nasadit staging bez změny kódu — třeba jste aktualizovali proměnnou prostředí nebo potřebujete znovu stáhnout základní Docker image.

Jedna věc, která lidi zaskočí: pull_request se spouští proti merge commitu, ne proti HEAD větve PR. To znamená, že vaše CI testuje, jak bude kód vypadat po mergnutí. To je vlastně to, co chcete, ale překvapí to lidi, když zelená větev zčervená po rebase.

Joby, kroky a 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 běží ve výchozím stavu paralelně. Každý job dostane čistý VM (tzv. "runner"). ubuntu-latest vám dá poměrně výkonný stroj — 4 vCPU, 16 GB RAM (k roku 2026). To je zdarma pro veřejné repozitáře, 2000 minut/měsíc pro soukromé.

Kroky (steps) běží sekvenčně v rámci jobu. Každý krok uses: stáhne znovupoužitelnou akci z marketplace. Každý krok run: vykoná příkaz v shellu.

Příznak --frozen-lockfile je klíčový. Bez něj by pnpm install mohl aktualizovat váš lockfile během CI, což znamená, že netestujete stejné závislosti, které vývojář commitnul. Viděl jsem, jak to způsobilo záhadná selhání testů, která lokálně zmizela, protože lockfile na stroji vývojáře byl už správný.

Proměnné prostředí vs Secrets#

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"

Proměnné prostředí nastavené pomocí env: na úrovni workflow jsou prostý text, viditelné v logách. Používejte je pro necitlivou konfiguraci: NODE_ENV, přepínače telemetrie, feature flagy.

Secrets (${{ secrets.X }}) jsou zašifrované v klidu, maskovány v logách a dostupné pouze pro workflow ve stejném repozitáři. Nastavují se v Settings > Secrets and variables > Actions.

Řádek environment: production je důležitý. GitHub Environments vám umožňují omezit secrets na konkrétní deployment cíle. Váš staging SSH klíč a váš produkční SSH klíč se oba mohou jmenovat SSH_PRIVATE_KEY, ale budou mít různé hodnoty v závislosti na tom, jaký environment job cílí. To také odemyká povinné recenzenty — můžete bránit produkční nasazení manuálním schválením.

Kompletní CI Pipeline#

Zde je struktura CI poloviny pipeline. Cíl: zachytit každou kategorii chyb v co nejkratším čase.

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

Proč tato struktura#

Lint, typecheck a test běží paralelně. Nemají mezi sebou žádné závislosti. Typová chyba neblokuje běh lintu a neúspěšný test nemusí čekat na type checker. Při typickém běhu se všechny tři dokončí za 30–60 sekund, zatímco běží současně.

Build čeká na všechny tři. Řádek needs: [lint, typecheck, test] znamená, že build job se spustí pouze pokud lint, typecheck A test projdou. Nemá smysl buildovat projekt, který má chyby v lintu nebo typech.

concurrency s cancel-in-progress: true je obrovská úspora času. Pokud pushujete dva commity rychle za sebou, první CI běh se zruší. Bez toho budete mít zastaralé běhy, které spotřebovávají váš rozpočet minut a zahlcují UI kontrol.

Upload pokrytí s if: always() znamená, že dostanete report pokrytí i když testy selžou. To je užitečné pro ladění — vidíte, které testy selhaly a co pokrývaly.

Fail-Fast vs. Nechat je všechny doběhnout#

Ve výchozím nastavení, pokud jeden job v matici selže, GitHub zruší ostatní. Pro CI toto chování vlastně chci — pokud selže lint, nezajímají mě výsledky testů. Nejdřív oprav lint.

Ale pro testovací matice (řekněme, testování na Node 20 a Node 22) můžete chtít vidět všechna selhání najednou:

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 nechá obě větve matice doběhnout. Pokud Node 22 selže, ale Node 20 projde, tu informaci vidíte okamžitě, místo abyste museli znovu spouštět.

Cachování pro rychlost#

Jediné největší zlepšení, které můžete udělat pro rychlost CI, je cachování. Studený pnpm install na středně velkém projektu trvá 30–45 sekund. S teplou cache trvá 3–5 sekund. Vynásobte to čtyřmi paralelními joby a ušetříte dvě minuty na každém běhu.

Cache pnpm store#

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

Tento jeden řádek cachuje pnpm store (~/.local/share/pnpm/store). Při cache hitu pnpm install --frozen-lockfile jen vytvoří hard linky ze store místo stahování. Samo o sobě to zkrátí dobu instalace o 80 % při opakovaných bězích.

Pokud potřebujete větší kontrolu — řekněme, že chcete cachovat i podle OS — použijte actions/cache přímo:

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

Záložní restore-keys je důležitý. Pokud se pnpm-lock.yaml změní (nová závislost), přesný klíč nebude odpovídat, ale prefixový match stále obnoví většinu cachovaných balíčků. Stáhne se pouze rozdíl.

Cache buildu Next.js#

Next.js má vlastní build cache v .next/cache. Cachování mezi běhy znamená inkrementální buildy — překompilují se pouze změněné stránky a komponenty.

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

Tato tříúrovňová strategie klíčů znamená:

  1. Přesná shoda: stejné závislosti A stejné zdrojové soubory. Plný cache hit, build je téměř okamžitý.
  2. Částečná shoda (závislosti): závislosti stejné, ale zdrojový kód se změnil. Build překompiluje pouze změněné soubory.
  3. Částečná shoda (pouze OS): závislosti se změnily. Build znovu použije, co může.

Reálná čísla z mého projektu: studený build trvá ~55 sekund, cachovaný build trvá ~15 sekund. To je 73% snížení.

Docker Layer Caching#

Docker buildy jsou oblast, kde cachování má opravdu velký dopad. Kompletní Docker build Next.js — instalace OS závislostí, kopírování zdrojového kódu, spuštění pnpm install, spuštění next build — trvá 3–4 minuty za studena. S layer cachováním je 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 používá vestavěný cache backend GitHub Actions. mode=max cachuje všechny vrstvy, ne jen finální. To je klíčové pro multi-stage buildy, kde jsou mezilehlé vrstvy (jako pnpm install) nejnákladnější na přebudování.

Turborepo Remote Cache#

Pokud jste v monorepu s Turborepo, vzdálené cachování je transformativní. První build nahraje výstupy úloh do cache. Následné buildy stahují místo přepočítávání.

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

Viděl jsem časy CI v monorepu klesnout z 8 minut na 90 sekund s Turbo remote cache. Háček: vyžaduje to Vercel účet nebo self-hosted Turbo server. Pro repozitáře s jednou aplikací je to přehnané.

Docker Build a Push#

Pokud nasazujete na VPS (nebo jakýkoli server), Docker vám dává reprodukovatelné buildy. Stejný image, který běží v CI, je stejný image, který běží na produkci. Žádné další "na mém stroji to funguje", protože stroj je ten image.

Multi-Stage Dockerfile#

Než se dostaneme k workflow, zde je Dockerfile, který používám pro Next.js:

dockerfile
# Fáze 1: Závislosti
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
 
# Fáze 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
 
# Fáze 3: Produkce
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"]

Tři fáze, jasné oddělení. Finální image má ~150 MB místo ~1,2 GB, které byste dostali kopírováním všeho. Do runner fáze se dostanou pouze produkční artefakty.

Workflow pro 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

Pojďme rozbalit důležitá rozhodnutí.

GitHub Container Registry (ghcr.io)#

Používám ghcr.io místo Docker Hub ze tří důvodů:

  1. Autentizace je zdarma. GITHUB_TOKEN je automaticky dostupný v každém workflow — není potřeba ukládat přihlašovací údaje do Docker Hub.
  2. Blízkost. Image se stahují ze stejné infrastruktury, na které běží vaše CI. Stahování během CI je rychlé.
  3. Viditelnost. Image jsou propojeny s vaším repozitářem v GitHub UI. Vidíte je v záložce Packages.

Multi-Platform Buildy#

yaml
platforms: linux/amd64,linux/arm64

Tento řádek přidá možná 90 sekund k vašemu buildu, ale stojí to za to. ARM64 image běží nativně na:

  • Apple Silicon Macech (M1/M2/M3/M4) při lokálním vývoji s Docker Desktop
  • AWS Graviton instancích (20–40 % levnější než x86 ekvivalenty)
  • Free ARM tier Oracle Cloud

Bez toho vaši vývojáři na M-series Macech provozují x86 image přes Rosetta emulaci. Funguje to, ale je to znatelně pomalejší a občas se objeví podivné chyby specifické pro architekturu.

QEMU poskytuje vrstvu pro křížovou kompilaci. Buildx orchestruje multi-arch build a pushuje manifest list, takže Docker automaticky stahuje správnou architekturu.

Strategie tagování#

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

Každý image dostane tři tagy:

  • abc1234 (SHA commitu): Neměnný. Vždy můžete nasadit přesný commit.
  • main (název větve): Měnný. Ukazuje na nejnovější build z dané větve.
  • latest: Měnný. Nastaven pouze na výchozí větvi. To je to, co váš server stahuje.

Nikdy nenasazujte latest na produkci, aniž byste někde zaznamenali také SHA. Když se něco rozbije, potřebujete vědět, který latest. SHA nasazeného commitu ukládám do souboru na serveru, který čte health endpoint.

SSH Deployment na VPS#

Tady se to všechno spojí dohromady. CI prošlo, Docker image je buildnutý a pushnutý, teď potřebujeme říct serveru, aby stáhl nový image a restartoval.

SSH akce#

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 "=== Nasazování $DEPLOY_SHA ==="
 
          # Stáhnout nejnovější image
          docker pull "$IMAGE"
 
          # Zastavit a odebrat starý kontejner
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # Spustit nový kontejner
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # Čekat na health check
          echo "Čekám na 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 prošel při pokusu $i"
              break
            fi
            if [ "$i" -eq 30 ]; then
              echo "Health check selhal po 30 pokusech"
              exit 1
            fi
            sleep 2
          done
 
          # Zaznamenat nasazené SHA
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # Vyčistit staré image
          docker image prune -af --filter "until=168h"
 
          echo "=== Nasazení dokončeno ==="

Alternativa s Deploy skriptem#

Pro cokoli složitějšího než jednoduché stáhni-a-restartuj přesouvám logiku do skriptu na serveru místo jeho vkládání do 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 "Zahajuji nasazení..."
 
# Přihlášení do GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# Stažení s opakováním
for attempt in 1 2 3; do
  if docker pull "$IMAGE"; then
    log "Image úspěšně stažen při pokusu $attempt"
    break
  fi
  if [ "$attempt" -eq 3 ]; then
    log "CHYBA: Nepodařilo se stáhnout image po 3 pokusech"
    exit 1
  fi
  log "Pokus o stažení $attempt selhal, opakuji za 5s..."
  sleep 5
done
 
# Funkce health checku
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
}
 
# Spustit nový kontejner na alternativním portu
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# Ověřit, že nový kontejner je zdravý
if ! health_check 3001; then
  log "CHYBA: Nový kontejner neprošel health checkem. Vracím zpět."
  docker stop akousa-app-new || true
  docker rm akousa-app-new || true
  exit 1
fi
 
log "Nový kontejner je zdravý. Přepínám provoz..."
 
# Přepnout 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
 
# Zastavit starý kontejner
docker stop akousa-app || true
docker rm akousa-app || true
 
# Přejmenovat nový kontejner
docker rename akousa-app-new akousa-app
 
log "Nasazení dokončeno."

Workflow se pak stane jedním SSH příkazem:

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

To je lepší, protože: (1) logika nasazení je verzována na serveru, (2) můžete ji spustit ručně přes SSH pro ladění a (3) nemusíte escapovat YAML uvnitř YAML uvnitř bashe.

Strategie pro nulový výpadek#

"Nulový výpadek" zní jako marketingová fráze, ale má přesný význam: žádný požadavek nedostane odmítnutí spojení nebo 502 během nasazení. Zde jsou tři skutečné přístupy, od nejjednoduššího po nejrobustnější.

Strategie 1: PM2 Cluster Mode Reload#

Pokud provozujete Node.js přímo (ne v Dockeru), cluster mode PM2 vám dá nejsnazší cestu k nulovému výpadku.

bash
# ecosystem.config.js už obsahuje:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (ne restart) provede postupný restart. Spustí nové workery, počká na jejich připravenost, pak postupně ukončí staré workery. V žádném okamžiku není nula workerů obsluhujících provoz.

Příznak --update-env znovu načte proměnné prostředí z ecosystem konfigurace. Bez něj vaše staré env přetrvá i po nasazení, které změnilo .env.

Ve vašem 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

Tohle používám pro tento web. Je to jednoduché, spolehlivé a výpadek je doslova nulový — testoval jsem to s generátorem zátěže běžícím 100 req/s během nasazení. Ani jedna 5xx.

Strategie 2: Blue/Green s Nginx Upstream#

Pro Docker deploymenty vám blue/green dává čisté oddělení mezi starou a novou verzí.

Koncept: spustit starý kontejner ("blue") na portu 3000 a nový kontejner ("green") na portu 3001. Nginx směřuje na blue. Spustíte green, ověříte, že je zdravý, přepnete Nginx na green, pak zastavíte blue.

Nginx upstream konfigurace:

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

Přepínací skript:

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 "Aktuální: $OLD_PORT -> Nový: $NEW_PORT"
 
# Spustit nový kontejner na alternativním portu
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"
 
# Čekat na health check
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "Nový kontejner zdravý na portu $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Health check selhal"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# Přepnout 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
 
# Zastavit starý kontejner
sleep 5  # Nechat dokončit probíhající požadavky
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Přepnuto z :$OLD_PORT na :$NEW_PORT"

5sekundový sleep po reloadu Nginx není lenost — je to čas odkladu. Reload Nginx je graceful (existující spojení zůstávají otevřená), ale některá long-polling spojení nebo streaming odpovědi potřebují čas na dokončení.

Strategie 3: Docker Compose s Health Checky#

Pro strukturovanější přístup může Docker Compose řídit blue/green výměnu:

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"

Klíčový řádek je order: start-first. Znamená "spustit nový kontejner před zastavením starého." V kombinaci s parallelism: 1 dostanete postupnou aktualizaci — jeden kontejner najednou, vždy s udržením kapacity.

Nasaďte pomocí:

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

Docker Compose sleduje healthcheck a nepřesměruje provoz na nový kontejner, dokud neprojde. Pokud healthcheck selže, failure_action: rollback automaticky vrátí předchozí verzi. To je tak blízko deploymentům ve stylu Kubernetes, jak se dá dostat na jednom VPS.

Správa secrets#

Správa secrets je jedna z těch věcí, které se snadno dají udělat "většinou správně" a katastrofálně špatně v zbývajících okrajových případech.

GitHub Secrets: Základy#

yaml
# Nastavení přes GitHub UI: Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # Hodnota je maskována v logách
      echo "Připojuji se k databázi..."
      # Toto by vytisklo "Připojuji se k ***" v logách
      echo "Připojuji se k $DB_URL"

GitHub automaticky odstraní hodnoty secrets z výstupu logů. Pokud je váš secret p@ssw0rd123 a jakýkoli krok ten řetězec vytiskne, logy ukážou ***. Funguje to dobře, s jednou výhradou: pokud je váš secret krátký (jako 4místný PIN), GitHub ho nemusí zamaskovat, protože by mohl odpovídat nevinným řetězcům. Udržujte secrets rozumně komplexní.

Environment-Scoped Secrets#

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: echo "Nasazuji na ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = staging.akousa.net
 
  deploy-production:
    environment: production
    steps:
      - run: echo "Nasazuji na ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = akousa.net

Stejný název secretu, různé hodnoty pro každý environment. Pole environment na jobu určuje, která sada secrets se vloží.

Produkční environment by měly mít zapnuté povinné recenzenty. To znamená, že push do main spustí workflow, CI běží automaticky, ale deploy job se pozastaví a čeká, až někdo klikne "Approve" v GitHub UI. Pro sólo projekt to může působit jako zbytečná režie. Pro cokoli s uživateli je to záchrana v okamžiku, kdy omylem mergujete něco rozbitého.

OIDC: Žádné statické přihlašovací údaje#

Statické přihlašovací údaje (AWS access keys, GCP service account JSON soubory) uložené v GitHub Secrets jsou závazek. Nevyprší, nelze je omezit na konkrétní běh workflow, a pokud uniknou, musíte je ručně rotovat.

OIDC (OpenID Connect) to řeší. GitHub Actions funguje jako poskytovatel identity a váš cloudový provider mu důvěřuje pro vydávání krátkodobých přihlašovacích údajů za běhu:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Vyžadováno pro 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

Žádný access key. Žádný secret key. Akce configure-aws-credentials vyžádá dočasný token od AWS STS pomocí OIDC tokenu GitHubu. Token je omezen na konkrétní repozitář, větev a environment. Vyprší po skončení běhu workflow.

Nastavení na straně AWS vyžaduje IAM OIDC identity providera a trust policy pro roli:

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

Podmínka sub je klíčová. Bez ní by jakýkoli repozitář, který by nějakým způsobem získal detaily vašeho OIDC providera, mohl převzít roli. S ní může pouze větev main vašeho konkrétního repozitáře.

GCP má ekvivalentní nastavení s Workload Identity Federation. Azure má federated credentials. Pokud váš cloud podporuje OIDC, používejte ho. V roce 2026 není důvod ukládat statické cloudové přihlašovací údaje.

Deploy SSH klíče#

Pro VPS nasazení přes SSH vygenerujte dedikovaný pár klíčů:

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

Přidejte veřejný klíč do ~/.ssh/authorized_keys na serveru s omezeními:

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

Prefix restrict zakáže port forwarding, agent forwarding, PTY alokaci a X11 forwarding. Prefix command= znamená, že tento klíč může pouze spustit deploy skript. I kdyby byl soukromý klíč kompromitován, útočník může spustit váš deploy skript a nic víc.

Přidejte soukromý klíč do GitHub Secrets jako SSH_PRIVATE_KEY. Toto je jediný statický přihlašovací údaj, který akceptuji — SSH klíče s vynucenými příkazy mají velmi omezený blast radius.

PR Workflow: Preview Deploymenty#

Každý PR si zaslouží preview prostředí. Zachytává vizuální chyby, které unit testy přehlédnou, umožňuje designérům recenzovat bez checkoutování kódu a dramaticky usnadňuje život QA.

Deploy preview při otevření 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 }}\`_`;
 
            // Najít existující komentář
            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,
              });
            }

Výpočet portu (4000 + PR_NUM) je pragmatický hack. PR #42 dostane port 4042. Pokud nemáte více než pár stovek otevřených PR, ke kolizím nedojde. Wildcard konfigurace Nginx směruje pr-*.preview.akousa.net na správný port.

Čištění při zavření PR#

Preview prostředí, která nejsou uklizena, zabírají disk a paměť. Přidejte úklidový job:

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 pro PR #${PR_NUM} uklizeno."
 
      - 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',
              });
            }

Povinné status kontroly#

V nastavení vašeho repozitáře (Settings > Branches > Branch protection rules) vyžadujte tyto kontroly před mergnutím:

  • lint — Žádné chyby lintu
  • typecheck — Žádné typové chyby
  • test — Všechny testy prošly
  • build — Projekt se úspěšně buildí

Bez toho někdo mergne PR s neúspěšnými kontrolami. Ne úmyslně — uvidí "2 ze 4 kontrol prošlo" a předpokládají, že ostatní dvě ještě běží. Zamkněte to.

Také zapněte "Vyžadovat aktuálnost větví před mergnutím." Toto vynutí opětovný běh CI po rebase na nejnovější main. Zachytí případ, kdy dva PR jednotlivě projdou CI, ale po kombinaci konfliktují.

Notifikace#

Nasazení, o kterém nikdo neví, je nasazení, kterému nikdo nevěří. Notifikace uzavírají zpětnou vazbu.

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() je kritický. Bez něj se krok notifikace přeskočí, když nasazení selže — což je přesně ten moment, kdy ji nejvíc potřebujete.

GitHub Deployments API#

Pro bohatší sledování nasazení použijte GitHub Deployments API. Dává vám historii nasazení v UI repozitáře a umožňuje stavové odznaky:

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: `Nasazuji ${context.sha.substring(0, 7)} na produkci`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... samotné kroky nasazení ...
 
- 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'
          ? 'Nasazení úspěšné'
          : 'Nasazení selhalo',
      });

Nyní vaše záložka Environments v GitHubu ukazuje kompletní historii nasazení: kdo nasadil co, kdy a zda to bylo úspěšné.

Email pouze při selhání#

Pro kritická nasazení také spouštím email při selhání. Ne přes vestavěný email GitHub Actions (příliš hlučný), ale přes cílený webhook:

yaml
- name: Alert on failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "NASAZENÍ SELHALO: ${{ github.repository }}",
        "body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'

Toto je moje poslední linie obrany. Slack je skvělý, ale je také hlučný — lidé ztlumují kanály. Email "NASAZENÍ SELHALO" s odkazem na běh přitáhne pozornost.

Kompletní soubor workflow#

Zde je vše propojené do jednoho, produkčně připraveného workflow. Toto je velmi blízké tomu, co skutečně nasazuje tento web.

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, typová kontrola a testy paralelně
  # ============================================================
 
  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: Pouze po úspěšném 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: Build a push image (pouze main větev)
  # ============================================================
 
  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 na VPS a aktualizace
  # ============================================================
 
  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 zahájen $(date) ==="
 
            # Stáhnout nový image
            docker pull "$IMAGE"
 
            # Spustit nový kontejner na alternativním portu
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # Health check
            echo "Spouštím 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 prošel (pokus $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "CHYBA: Health check selhal"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # Přepnout provoz
            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
 
            # Čas odkladu pro probíhající požadavky
            sleep 5
 
            # Zastavit starý kontejner
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # Přejmenovat a resetovat port
            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
            # Poznámka: Nginx tady nerelodujeme, protože se změnil název kontejneru,
            # ne port. Příští deploy použije správný port.
 
            # Zaznamenat nasazení
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # Vyčistit staré image (starší než 7 dní)
            docker image prune -af --filter "until=168h"
 
            echo "=== Deploy dokončen $(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": "NASAZENÍ SELHALO: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

Průchod celým tokem#

Když pushnu do main:

  1. Lint, Type Check a Test se spustí současně. Tři runnery, tři paralelní joby. Pokud jakýkoli selže, pipeline se zastaví.
  2. Build se spustí pouze pokud všechny tři projdou. Ověří, že se aplikace zkompiluje a vytvoří funkční výstup.
  3. Docker buildne produkční image a pushne ho na ghcr.io. Multi-platformní, s layer cachováním.
  4. Deploy se připojí přes SSH na VPS, stáhne nový image, spustí nový kontejner, provede health check, přepne Nginx a uklidí.
  5. Notifikace se odešlou bez ohledu na výsledek. Slack dostane zprávu. GitHub Deployments se aktualizují. Pokud selhalo, odejde alertový email.

Když otevřu PR:

  1. Lint, Type Check a Test se spustí. Stejné quality gates.
  2. Build se spustí pro ověření, že se projekt zkompiluje.
  3. Docker a Deploy se přeskočí (podmínky if je omezují pouze na větev main).

Když potřebuji nouzové nasazení (přeskočit testy):

  1. Kliknu na "Run workflow" v záložce Actions.
  2. Vyberu skip_tests: true.
  3. Lint a typecheck stále běží (ty přeskočit nejde — sám sobě nevěřím natolik).
  4. Testy se přeskočí, build proběhne, Docker buildne, deploy se spustí.

Toto je můj workflow po dva roky. Přežil migrace serverů, upgrady hlavních verzí Node.js, nahrazení npm pnpm a přidání 15 nástrojů na tento web. Celková doba od pushe po produkci: 3 minuty 40 sekund v průměru. Nejpomalejší krok je multi-platformní Docker build s ~90 sekundami. Vše ostatní je cachované na téměř okamžité.

Ponaučení z dvou let iterací#

Zakončím chybami, které jsem udělal, abyste je nemuseli opakovat.

Připněte verze akcí. uses: actions/checkout@v4 je v pořádku, ale pro produkci zvažte uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (celé SHA). Kompromitovaná akce by mohla exfiltrovat vaše secrets. Incident tj-actions/changed-files v roce 2025 dokázal, že to není teoretické.

Necachujte všechno. Jednou jsem cachoval node_modules přímo (ne jen pnpm store) a strávil dvě hodiny laděním záhadného selhání buildu způsobeného zastaralými nativními vazbami. Cachujte store správce balíčků, ne nainstalované moduly.

Nastavte timeouty. Každý job by měl mít timeout-minutes. Výchozí je 360 minut (6 hodin). Pokud vaše nasazení zamrzne, protože SSH spojení spadlo, nechcete to zjistit o šest hodin později, až jste vyčerpali měsíční minuty.

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

Používejte concurrency uvážlivě. Pro PR je cancel-in-progress: true vždy správné — nikoho nezajímá výsledek CI commitu, který už byl přepsán force-pushem. Pro produkční nasazení nastavte na false. Nechcete, aby rychle následující commit zrušil nasazení, které je uprostřed rolloutu.

Testujte svůj workflow soubor. Použijte act (https://github.com/nektos/act) pro lokální spouštění workflow. Nezachytí to všechno (secrets nejsou k dispozici a prostředí runneru se liší), ale zachytí to syntaktické chyby v YAML a zjevné logické chyby dříve, než pushnete.

Monitorujte své CI náklady. Minuty GitHub Actions jsou zdarma pro veřejné repozitáře a levné pro soukromé, ale sčítají se. Multi-platformní Docker buildy stojí 2x minuty (jeden za platformu). Maticové testovací strategie násobí váš runtime. Sledujte stránku fakturace.

Nejlepší CI/CD pipeline je ta, které věříte. Důvěra pramení ze spolehlivosti, pozorovatelnosti a postupného zlepšování. Začněte jednoduchou lint-test-build pipeline. Přidejte Docker, když potřebujete reprodukovatelnost. Přidejte SSH deployment, když potřebujete automatizaci. Přidejte notifikace, když potřebujete jistotu. Nestavte kompletní pipeline první den — abstrakce budete mít špatně.

Postavte pipeline, kterou potřebujete dnes, a nechte ji růst spolu s vaším projektem.

Související články