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.
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í#
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:
pushdomainje váš trigger pro produkční nasazení. Kód mergnutý? Jede se.pull_requestspouští vaše CI kontroly na každém PR. Tady žijí lint, typové kontroly a testy.scheduleje cron pro váš repozitář. Používám ho pro týdenní audity závislostí a čištění zastaralé cache.workflow_dispatchvá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#
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 lintJoby 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#
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.
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: 1Proč 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:
test:
strategy:
fail-fast: false
matrix:
node-version: [20, 22]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm testfail-fast: false 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#
- 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:
- 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.
- 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á:
- Přesná shoda: stejné závislosti A stejné zdrojové soubory. Plný cache hit, build je téměř okamžitý.
- Částečná shoda (závislosti): závislosti stejné, ale zdrojový kód se změnil. Build překompiluje pouze změněné soubory.
- Čá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.
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=maxtype=gha 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í.
- 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:
# 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#
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=maxPojďme rozbalit důležitá rozhodnutí.
GitHub Container Registry (ghcr.io)#
Používám ghcr.io místo Docker Hub ze tří důvodů:
- Autentizace je zdarma.
GITHUB_TOKENje automaticky dostupný v každém workflow — není potřeba ukládat přihlašovací údaje do Docker Hub. - Blízkost. Image se stahují ze stejné infrastruktury, na které běží vaše CI. Stahování během CI je rychlé.
- Viditelnost. Image jsou propojeny s vaším repozitářem v GitHub UI. Vidíte je v záložce Packages.
Multi-Platform Buildy#
platforms: linux/amd64,linux/arm64Tento řá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í#
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#
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:
#!/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:
script: |
cd /var/www/akousa.net && ./deploy.shTo 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.
# ecosystem.config.js už obsahuje:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 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:
- 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-envTohle 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:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /etc/nginx/sites-available/akousa.net
server {
listen 443 ssl http2;
server_name akousa.net;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}Přepínací skript:
#!/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:
# 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í:
docker compose pull
docker compose up -d --remove-orphansDocker 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#
# 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#
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.netStejný 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:
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:
{
"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íčů:
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#
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:
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 lintutypecheck— Žádné typové chybytest— Všechny testy prošlybuild— 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#
- 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:
- 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:
- 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.
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 }}"
}' || truePrůchod celým tokem#
Když pushnu do main:
- Lint, Type Check a Test se spustí současně. Tři runnery, tři paralelní joby. Pokud jakýkoli selže, pipeline se zastaví.
- Build se spustí pouze pokud všechny tři projdou. Ověří, že se aplikace zkompiluje a vytvoří funkční výstup.
- Docker buildne produkční image a pushne ho na ghcr.io. Multi-platformní, s layer cachováním.
- Deploy se připojí přes SSH na VPS, stáhne nový image, spustí nový kontejner, provede health check, přepne Nginx a uklidí.
- 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:
- Lint, Type Check a Test se spustí. Stejné quality gates.
- Build se spustí pro ověření, že se projekt zkompiluje.
- Docker a Deploy se přeskočí (podmínky
ifje omezují pouze na větevmain).
Když potřebuji nouzové nasazení (přeskočit testy):
- Kliknu na "Run workflow" v záložce Actions.
- Vyberu
skip_tests: true. - Lint a typecheck stále běží (ty přeskočit nejde — sám sobě nevěřím natolik).
- 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.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestPouží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.