GitHub Actions CI/CD: Zero-downtime deploymenty, które naprawdę działają
Moja kompletna konfiguracja GitHub Actions: równoległe joby testowe, cachowanie buildów Docker, deployment SSH na VPS, zero-downtime z PM2 reload, zarządzanie sekretami i wzorce workflow, które szlifowałem przez dwa lata.
Każdy projekt, nad którym pracowałem, w końcu dochodzi do tego samego punktu zwrotnego: proces deployu staje się zbyt bolesny, żeby robić go ręcznie. Zapominasz uruchomić testy. Budujesz lokalnie, ale zapominasz podbić wersję. SSHujesz się na produkcję i odkrywasz, że ostatnia osoba, która deployowała, zostawiła nieaktualny plik .env.
GitHub Actions rozwiązał to dla mnie dwa lata temu. Nie idealnie od pierwszego dnia — pierwszy workflow, który napisałem, to był 200-liniowy YAML-owy koszmar, który wywalał się po timeout w połowie przypadków i nic nie cachował. Ale iteracja po iteracji doszedłem do czegoś, co deployuje tę stronę niezawodnie, z zero downtime, w mniej niż cztery minuty.
To jest ten workflow, wyjaśniony sekcja po sekcji. Nie wersja z dokumentacji. Wersja, która przetrwała kontakt z produkcją.
Zrozumienie elementów składowych#
Zanim przejdziemy do pełnego pipeline'u, musisz mieć jasny model mentalny tego, jak GitHub Actions działa. Jeśli używałeś Jenkinsa lub CircleCI, zapomnij większość tego, co wiesz. Koncepcje mapują się luźno, ale model wykonania jest na tyle inny, że może cię potknąć.
Triggery: Kiedy twój workflow się uruchamia#
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # Every Monday at 6 AM UTC
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: "staging"
type: choice
options:
- staging
- productionCztery triggery, każdy służący innemu celowi:
pushnamainto twój trigger deployu produkcyjnego. Kod zmergowany? Wysyłaj.pull_requesturuchamia sprawdzenia CI na każdym PR. Tu żyją lint, sprawdzenia typów i testy.scheduleto cron dla twojego repozytorium. Używam go do cotygodniowych audytów zależności i czyszczenia nieaktualnych cache'ów.workflow_dispatchdaje ci ręczny przycisk "Deploy" w interfejsie GitHub z parametrami wejściowymi. Nieocenione, gdy musisz deployować staging bez zmiany kodu — może zaktualizowałeś zmienną środowiskową lub musisz ponownie pobrać bazowy obraz Docker.
Jedna rzecz, która gryzie ludzi: pull_request uruchamia się na merge commit, nie na HEAD brancha PR. To znaczy, że twoje CI testuje, jak kod będzie wyglądał po merge. To właściwie to, czego chcesz, ale zaskakuje ludzi, gdy zielony branch staje się czerwony po rebase.
Joby, kroki i runnery#
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 domyślnie działają równolegle. Każdy job dostaje świeżą maszynę wirtualną ("runner"). ubuntu-latest daje ci dość wydajną maszynę — 4 vCPU, 16 GB RAM według stanu na 2026. To jest darmowe dla publicznych repozytoriów, 2000 minut/miesiąc dla prywatnych.
Kroki działają sekwencyjnie w obrębie joba. Każdy krok uses: wciąga akcję wielokrotnego użytku z marketplace. Każdy krok run: wykonuje komendę powłoki.
Flaga --frozen-lockfile jest kluczowa. Bez niej pnpm install może zaktualizować twój lockfile podczas CI, co oznacza, że nie testujesz tych samych zależności, które developer commitnął. Widziałem, jak to powodowało fantomowe niepowodzenia testów, które znikały lokalnie, bo lockfile na maszynie developera był już prawidłowy.
Zmienne środowiskowe vs sekrety#
env:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: 1
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
echo "$SSH_PRIVATE_KEY" > key.pem
chmod 600 key.pem
ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"Zmienne środowiskowe ustawione z env: na poziomie workflow to zwykły tekst, widoczny w logach. Używaj ich do niewrażliwej konfiguracji: NODE_ENV, flagi telemetrii, przełączniki funkcji.
Sekrety (${{ secrets.X }}) są zaszyfrowane w spoczynku, maskowane w logach i dostępne tylko dla workflow w tym samym repozytorium. Ustawia się je w Settings > Secrets and variables > Actions.
Linia environment: production jest znacząca. GitHub Environments pozwalają ograniczyć sekrety do określonych celów deploymentu. Twój klucz SSH stagingu i klucz SSH produkcji mogą oba nazywać się SSH_PRIVATE_KEY, ale trzymać różne wartości w zależności od tego, na które środowisko celuje job. To też odblokowuje wymaganych recenzentów — możesz zabramkować deploye produkcyjne za ręcznym zatwierdzeniem.
Pełny pipeline CI#
Oto jak strukturyzuję połowę CI pipeline'u. Cel: złapać każdą kategorię błędu w najkrótszym możliwym czasie.
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: 1Dlaczego ta struktura#
Lint, typecheck i test działają równolegle. Nie mają między sobą zależności. Błąd typów nie blokuje linta, a nieudany test nie musi czekać na sprawdzenie typów. Na typowym uruchomieniu wszystkie trzy kończą się w 30-60 sekund, działając jednocześnie.
Build czeka na wszystkie trzy. Linia needs: [lint, typecheck, test] oznacza, że job build startuje tylko wtedy, gdy lint, typecheck I test przejdą pomyślnie. Nie ma sensu budować projektu, który ma błędy lintowania lub typów.
concurrency z cancel-in-progress: true to ogromna oszczędność czasu. Jeśli pushasz dwa commity szybko po sobie, pierwsze uruchomienie CI jest anulowane. Bez tego miałbyś nieaktualne uruchomienia zużywające twój budżet minut i zaśmiecające interfejs sprawdzeń.
Upload coverage z if: always() oznacza, że dostajesz raport pokrycia nawet gdy testy zawiodą. To jest użyteczne do debugowania — widzisz, które testy zawiodły i co pokrywały.
Fail-Fast vs. pozwól wszystkim działać#
Domyślnie, jeśli jeden job w macierzy zawodzi, GitHub anuluje pozostałe. Dla CI właściwie chcę tego zachowania — jeśli lint zawodzi, nie obchodzą mnie wyniki testów. Najpierw napraw lint.
Ale dla macierzy testowych (powiedzmy, testowanie na Node 20 i Node 22), możesz chcieć zobaczyć wszystkie niepowodzenia naraz:
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 pozwala obu nogom macierzy się zakończyć. Jeśli Node 22 zawodzi, ale Node 20 przechodzi, widzisz tę informację natychmiast zamiast musieć ponownie uruchamiać.
Cachowanie dla szybkości#
Pojedyncze największe ulepszenie, jakie możesz wprowadzić do szybkości CI, to cachowanie. Zimny pnpm install na średnim projekcie zajmuje 30-45 sekund. Z ciepłym cache'em zajmuje 3-5 sekund. Pomnóż to przez cztery równoległe joby i oszczędzasz dwie minuty na każdym uruchomieniu.
Cache store pnpm#
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"Ten jednolinijkowiec cachuje store pnpm (~/.local/share/pnpm/store). Przy trafieniu w cache pnpm install --frozen-lockfile po prostu tworzy hardlinki ze store zamiast pobierać. To samo tnie czas instalacji o 80% przy powtórnych uruchomieniach.
Jeśli potrzebujesz więcej kontroli — powiedzmy, chcesz cachować też na podstawie OS — użyj actions/cache bezpośrednio:
- uses: actions/cache@v4
with:
path: |
~/.local/share/pnpm/store
node_modules
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-Fallback restore-keys jest ważny. Jeśli pnpm-lock.yaml się zmieni (nowa zależność), dokładny klucz nie trafi, ale dopasowanie prefiksowe nadal przywróci większość cachowanych pakietów. Pobrane zostaną tylko różnice.
Cache buildu Next.js#
Next.js ma własny cache buildu w .next/cache. Cachowanie go między uruchomieniami oznacza inkrementalne buildy — tylko zmienione strony i komponenty są rekompilowane.
- uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
nextjs-${{ runner.os }}-Ta trzystopniowa strategia kluczy oznacza:
- Dokładne trafienie: te same zależności I te same pliki źródłowe. Pełne trafienie w cache, build jest niemal natychmiastowy.
- Częściowe trafienie (zależności): zależności te same, ale źródło się zmieniło. Build rekompiluje tylko zmienione pliki.
- Częściowe trafienie (tylko OS): zależności się zmieniły. Build wykorzystuje co może.
Realne liczby z mojego projektu: zimny build zajmuje ~55 sekund, build z cache'em zajmuje ~15 sekund. To 73% redukcji.
Cachowanie warstw Dockera#
Buildy Dockera to miejsce, gdzie cachowanie naprawdę robi różnicę. Pełny build Dockera Next.js — instalacja zależności OS, kopiowanie źródeł, uruchomienie pnpm install, uruchomienie next build — zajmuje 3-4 minuty na zimno. Z cachowaniem warstw to 30-60 sekund.
- 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 używa wbudowanego backendu cache GitHub Actions. mode=max cachuje wszystkie warstwy, nie tylko finalne. To jest krytyczne dla multi-stage buildów, gdzie pośrednie warstwy (jak pnpm install) są najdroższe do przebudowania.
Zdalny cache Turborepo#
Jeśli jesteś w monorepo z Turborepo, zdalny cache jest transformatywny. Pierwszy build wgrywa outputy tasków do cache. Kolejne buildy pobierają zamiast przeliczać.
- run: pnpm turbo build --remote-only
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Widziałem, jak czasy CI monorepo spadały z 8 minut do 90 sekund ze zdalnym cache Turbo. Haczyk: wymaga konta Vercel lub self-hosted serwera Turbo. Dla repozytoriów z jedną aplikacją to overkill.
Build i push Dockera#
Jeśli deployujesz na VPS (lub dowolny serwer), Docker daje ci reprodukowalne buildy. Ten sam obraz, który działa w CI, to ten sam obraz, który działa na produkcji. Koniec z "u mnie działa", bo maszyna jest obrazem.
Multi-Stage Dockerfile#
Zanim przejdziemy do workflow, oto Dockerfile, którego używam do Next.js:
# Stage 1: Dependencies
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
# Stage 2: Build
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Stage 3: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]Trzy etapy, jasne rozdzielenie. Finalny obraz to ~150MB zamiast ~1,2GB, które byś dostał kopiując wszystko. Tylko artefakty produkcyjne trafiają do etapu runner.
Workflow Build-and-Push#
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=maxPozwól, że rozpakuję ważne decyzje.
GitHub Container Registry (ghcr.io)#
Używam ghcr.io zamiast Docker Hub z trzech powodów:
- Autentykacja jest darmowa.
GITHUB_TOKENjest automatycznie dostępny w każdym workflow — nie trzeba przechowywać poświadczeń Docker Hub. - Bliskość. Obrazy są pobierane z tej samej infrastruktury, na której działa twoje CI. Pobrania podczas CI są szybkie.
- Widoczność. Obrazy są linkowane do twojego repozytorium w interfejsie GitHub. Widzisz je w zakładce Packages.
Buildy multi-platform#
platforms: linux/amd64,linux/arm64Ta linia dodaje może 90 sekund do twojego buildu, ale jest tego warta. Obrazy ARM64 działają natywnie na:
- Mackach z Apple Silicon (M1/M2/M3/M4) podczas lokalnego developmentu z Docker Desktop
- Instancjach AWS Graviton (20-40% tańszych niż odpowiedniki x86)
- Darmowym tierze ARM Oracle Cloud
Bez tego twoi developerzy na Mackach z serii M uruchamiają obrazy x86 przez emulację Rosetta. Działa, ale jest zauważalnie wolniejsze i okazjonalnie ujawnia dziwne bugi specyficzne dla architektury.
QEMU zapewnia warstwę cross-kompilacji. Buildx orkiestruje build multi-arch i pushuje manifest list, więc Docker automatycznie pobiera właściwą architekturę.
Strategia tagowania#
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}Każdy obraz dostaje trzy tagi:
abc1234(SHA commita): Niezmienny. Zawsze możesz deployować dokładny commit.main(nazwa brancha): Zmienny. Wskazuje na najnowszy build z tego brancha.latest: Zmienny. Ustawiany tylko na domyślnym branchu. To jest to, co twój serwer pobiera.
Nigdy nie deployuj latest na produkcji bez jednoczesnego zapisania SHA gdzieś. Gdy coś się zepsuje, musisz wiedzieć który latest. Przechowuję deployowane SHA w pliku na serwerze, który endpoint health odczytuje.
Deployment SSH na VPS#
Tu wszystko się łączy. CI przechodzi, obraz Dockera jest zbudowany i wypushowany, teraz musimy powiedzieć serwerowi, żeby pobrał nowy obraz i zrestartował.
Akcja SSH#
deploy:
name: Deploy to Production
needs: [build-and-push]
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
script: |
set -euo pipefail
APP_DIR="/var/www/akousa.net"
IMAGE="ghcr.io/${{ github.repository }}:latest"
DEPLOY_SHA="${{ github.sha }}"
echo "=== Deploying $DEPLOY_SHA ==="
# Pull the latest image
docker pull "$IMAGE"
# Stop and remove old container
docker stop akousa-app || true
docker rm akousa-app || true
# Start new container
docker run -d \
--name akousa-app \
--restart unless-stopped \
--network host \
-e NODE_ENV=production \
-e DATABASE_URL="${DATABASE_URL}" \
-p 3000:3000 \
"$IMAGE"
# Wait for health check
echo "Waiting for health check..."
for i in $(seq 1 30); do
if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
echo "Health check passed on attempt $i"
break
fi
if [ "$i" -eq 30 ]; then
echo "Health check failed after 30 attempts"
exit 1
fi
sleep 2
done
# Record deployed SHA
echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
# Prune old images
docker image prune -af --filter "until=168h"
echo "=== Deploy complete ==="Alternatywa ze skryptem deployu#
Dla czegokolwiek poza prostym pull-and-restart przenoszę logikę do skryptu na serwerze zamiast wstawiać ją inline w workflow:
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
log "Starting deployment..."
# Login to GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
# Pull with retry
for attempt in 1 2 3; do
if docker pull "$IMAGE"; then
log "Image pulled successfully on attempt $attempt"
break
fi
if [ "$attempt" -eq 3 ]; then
log "ERROR: Failed to pull image after 3 attempts"
exit 1
fi
log "Pull attempt $attempt failed, retrying in 5s..."
sleep 5
done
# Health check function
health_check() {
local port=$1
local max_attempts=30
for i in $(seq 1 $max_attempts); do
if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
# Start new container on alternate port
docker run -d \
--name akousa-app-new \
--env-file "$APP_DIR/.env.production" \
-p 3001:3000 \
"$IMAGE"
# Verify new container is healthy
if ! health_check 3001; then
log "ERROR: New container failed health check. Rolling back."
docker stop akousa-app-new || true
docker rm akousa-app-new || true
exit 1
fi
log "New container healthy. Switching traffic..."
# Switch Nginx upstream
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# Stop old container
docker stop akousa-app || true
docker rm akousa-app || true
# Rename new container
docker rename akousa-app-new akousa-app
log "Deployment complete."Workflow wtedy staje się jedną komendą SSH:
script: |
cd /var/www/akousa.net && ./deploy.shTo jest lepsze, bo: (1) logika deployu jest wersjonowana na serwerze, (2) możesz ją uruchomić ręcznie przez SSH do debugowania, i (3) nie musisz escapować YAML wewnątrz YAML wewnątrz basha.
Strategie zero-downtime#
"Zero downtime" brzmi jak marketing, ale ma precyzyjne znaczenie: żadne żądanie nie dostaje connection refused ani 502 podczas deploymentu. Oto trzy realne podejścia, od najprostszego do najbardziej solidnego.
Strategia 1: PM2 Cluster Mode Reload#
Jeśli uruchamiasz Node.js bezpośrednio (nie w Dockerze), tryb cluster PM2 daje ci najłatwiejszą ścieżkę do zero-downtime.
# ecosystem.config.js already has:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 reload (nie restart) robi rolling restart. Uruchamia nowe workery, czeka aż będą gotowe, potem zabija stare workery jeden po jednym. W żadnym momencie zero workerów nie obsługuje ruchu.
Flaga --update-env przeładowuje zmienne środowiskowe z konfiguracji ecosystem. Bez niej twoje stare env przetrwa nawet po deployu, który zmienił .env.
W twoim workflow:
- 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-envTego używam dla tej strony. To proste, niezawodne i downtime jest dosłownie zero — testowałem to z generatorem obciążenia robiącym 100 req/s podczas deployów. Ani jeden 5xx.
Strategia 2: Blue/Green z Nginx Upstream#
Dla deploymentów Dockera blue/green daje ci czyste rozdzielenie między starą i nową wersją.
Koncepcja: uruchom stary kontener ("blue") na porcie 3000 i nowy kontener ("green") na porcie 3001. Nginx wskazuje na blue. Uruchamiasz green, weryfikujesz że jest zdrowy, przełączasz Nginx na green, potem zatrzymujesz blue.
Konfiguracja Nginx upstream:
# /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;
}
}Skrypt przełączania:
#!/bin/bash
set -euo pipefail
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
if [ "$CURRENT_PORT" = "3000" ]; then
NEW_PORT=3001
OLD_PORT=3000
else
NEW_PORT=3000
OLD_PORT=3001
fi
echo "Current: $OLD_PORT -> New: $NEW_PORT"
# Start new container on the alternate port
docker run -d \
--name "akousa-app-$NEW_PORT" \
--env-file /var/www/akousa.net/.env.production \
-p "$NEW_PORT:3000" \
"ghcr.io/akousa/akousa-net:latest"
# Wait for health
for i in $(seq 1 30); do
if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
echo "New container healthy on port $NEW_PORT"
break
fi
[ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
sleep 2
done
# Switch Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# Stop old container
sleep 5 # Let in-flight requests complete
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
echo "Switched from :$OLD_PORT to :$NEW_PORT"5-sekundowy sleep po przeładowaniu Nginx to nie lenistwo — to czas łaski. Przeładowanie Nginx jest graceful (istniejące połączenia są utrzymywane), ale niektóre połączenia long-polling lub odpowiedzi streamingowe potrzebują czasu na zakończenie.
Strategia 3: Docker Compose z Health Checks#
Dla bardziej ustrukturyzowanego podejścia Docker Compose może zarządzać zamianą blue/green:
# docker-compose.yml
services:
app:
image: ghcr.io/akousa/akousa-net:latest
restart: unless-stopped
env_file: .env.production
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
order: start-first
failure_action: rollback
rollback_config:
parallelism: 0
order: stop-first
ports:
- "3000:3000"Linia order: start-first to klucz. Oznacza "uruchom nowy kontener przed zatrzymaniem starego". W połączeniu z parallelism: 1 dostajesz rolling update — jeden kontener naraz, zawsze utrzymując pojemność.
Deploy z:
docker compose pull
docker compose up -d --remove-orphansDocker Compose obserwuje healthcheck i nie kieruje ruchu do nowego kontenera, dopóki nie przejdzie. Jeśli healthcheck zawiedzie, failure_action: rollback automatycznie wraca do poprzedniej wersji. To jest najbliżej deploymentów rolling w stylu Kubernetes, jak możesz dojść na jednym VPS.
Zarządzanie sekretami#
Zarządzanie sekretami to jedna z tych rzeczy, które łatwo zrobić "prawie dobrze" i katastrofalnie źle w pozostałych przypadkach brzegowych.
Sekrety GitHub: Podstawy#
# Set via GitHub UI: Settings > Secrets and variables > Actions
steps:
- name: Use a secret
env:
DB_URL: ${{ secrets.DATABASE_URL }}
run: |
# The value is masked in logs
echo "Connecting to database..."
# This would print "Connecting to ***" in the logs
echo "Connecting to $DB_URL"GitHub automatycznie redukuje wartości sekretów z logów. Jeśli twój sekret to p@ssw0rd123 i jakikolwiek krok wydrukuje ten string, logi pokażą ***. To działa dobrze, z jednym zastrzeżeniem: jeśli twój sekret jest krótki (jak 4-cyfrowy PIN), GitHub może go nie zamaskować, bo mógłby pasować do niewinnych stringów. Trzymaj sekrety rozsądnie złożone.
OIDC: Koniec ze statycznymi poświadczeniami#
Statyczne poświadczenia (klucze dostępu AWS, pliki JSON kont serwisowych GCP) przechowywane w GitHub Secrets to zobowiązanie. Nie wygasają, nie mogą być ograniczone do konkretnego uruchomienia workflow, a jeśli wyciekną, musisz je ręcznie rotować.
OIDC (OpenID Connect) to rozwiązuje. GitHub Actions działa jako dostawca tożsamości, a twój dostawca chmury ufa mu w wystawianiu krótkotrwałych poświadczeń na bieżąco:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: eu-central-1
- name: Push to ECR
run: |
aws ecr get-login-password --region eu-central-1 | \
docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.comBez klucza dostępu. Bez klucza sekretnego. Akcja configure-aws-credentials żąda tymczasowego tokena od AWS STS używając tokena OIDC GitHub. Token jest ograniczony do konkretnego repozytorium, brancha i środowiska. Wygasa po uruchomieniu workflow.
GCP ma równoważną konfigurację z Workload Identity Federation. Azure ma federated credentials. Jeśli twoja chmura obsługuje OIDC, używaj go. Nie ma powodu przechowywać statycznych poświadczeń chmurowych w 2026 roku.
Klucze SSH do deployu#
Dla deploymentów VPS przez SSH wygeneruj dedykowaną parę kluczy:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""Dodaj klucz publiczny do ~/.ssh/authorized_keys serwera z ograniczeniami:
restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy
Prefiks restrict wyłącza port forwarding, agent forwarding, alokację PTY i X11 forwarding. Prefiks command= oznacza, że ten klucz może tylko wykonać skrypt deployu. Nawet jeśli klucz prywatny zostanie skompromitowany, atakujący może uruchomić twój skrypt deployu i nic więcej.
Dodaj klucz prywatny do GitHub Secrets jako SSH_PRIVATE_KEY. To jedyne statyczne poświadczenie, które akceptuję — klucze SSH z wymuszonymi komendami mają bardzo ograniczony blast radius.
Workflow PR: Preview Deployments#
Każdy PR zasługuje na środowisko preview. Łapie wizualne bugi, które testy jednostkowe pomijają, pozwala designerom przeglądać bez checkoutowania kodu i dramatycznie ułatwia życie QA.
Deploy preview przy otwarciu PR#
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
preview:
runs-on: ubuntu-latest
environment:
name: preview-${{ github.event.number }}
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v4
- name: Build preview image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Deploy preview
id: deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PREVIEW_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
PR_NUM=${{ github.event.number }}
PORT=$((4000 + PR_NUM))
IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
docker pull "$IMAGE"
docker stop "preview-${PR_NUM}" || true
docker rm "preview-${PR_NUM}" || true
docker run -d \
--name "preview-${PR_NUM}" \
--restart unless-stopped \
-e NODE_ENV=preview \
-p "${PORT}:3000" \
"$IMAGE"
echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
- name: Comment PR with preview URL
uses: actions/github-script@v7
with:
script: |
const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
const body = `### Preview Deployment
| Status | URL |
|--------|-----|
| :white_check_mark: Deployed | [${url}](${url}) |
_Last updated: ${new Date().toISOString()}_
_Commit: \`${{ github.sha }}\`_`;
// Find existing comment
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(c =>
c.user.type === 'Bot' && c.body.includes('Preview Deployment')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}Obliczanie portu (4000 + PR_NUM) to pragmatyczny hack. PR #42 dostaje port 4042. Dopóki nie masz więcej niż kilkaset otwartych PR, nie ma kolizji. Wildcard config Nginx routuje pr-*.preview.akousa.net do właściwego portu.
Czyszczenie przy zamknięciu PR#
Środowiska preview, które nie są czyszczone, zjadają dysk i pamięć. Dodaj job czyszczenia:
name: Cleanup Preview
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Remove preview container
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PREVIEW_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
PR_NUM=${{ github.event.number }}
docker stop "preview-${PR_NUM}" || true
docker rm "preview-${PR_NUM}" || true
docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
echo "Preview for PR #${PR_NUM} cleaned up."
- name: Deactivate environment
uses: actions/github-script@v7
with:
script: |
const deployments = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: `preview-${{ github.event.number }}`,
});
for (const deployment of deployments.data) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'inactive',
});
}Wymagane sprawdzenia statusu#
W ustawieniach repozytorium (Settings > Branches > Branch protection rules), wymagaj tych sprawdzeń przed merge'em:
lint— Brak błędów lintowaniatypecheck— Brak błędów typówtest— Wszystkie testy przechodząbuild— Projekt się buduje pomyślnie
Bez tego ktoś będzie mergował PR z nieprzechodzącymi sprawdzeniami. Nie złośliwie — zobaczy "2 z 4 sprawdzeń przeszło" i założy, że pozostałe dwa jeszcze działają. Zablokuj to.
Włącz też "Require branches to be up to date before merging." To wymusza ponowne uruchomienie CI po rebase na najnowszy main. Łapie przypadek, gdy dwa PR indywidualnie przechodzą CI, ale kolidują po połączeniu.
Powiadomienia#
Deployment, o którym nikt nie wie, to deployment, któremu nikt nie ufa. Powiadomienia zamykają pętlę zwrotną.
Webhook Slack#
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Repository:*\n${{ github.repository }}"
},
{
"type": "mrkdwn",
"text": "*Branch:*\n${{ github.ref_name }}"
},
{
"type": "mrkdwn",
"text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
},
{
"type": "mrkdwn",
"text": "*Triggered by:*\n${{ github.actor }}"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "View Run"
},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
]
}if: always() jest krytyczne. Bez niego krok powiadomienia jest pomijany, gdy deploy zawodzi — a to jest dokładnie wtedy, gdy najbardziej go potrzebujesz.
Lekcje z dwóch lat iteracji#
Zakończę błędami, które popełniłem, żebyś nie musiał.
Pinuj wersje akcji. uses: actions/checkout@v4 jest w porządku, ale na produkcji rozważ uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (pełne SHA). Skompromitowana akcja mogłaby wyekstrahować twoje sekrety. Incydent tj-actions/changed-files w 2025 udowodnił, że to nie jest teoretyczne.
Nie cachuj wszystkiego. Kiedyś cachowałem node_modules bezpośrednio (nie tylko store pnpm) i spędziłem dwie godziny debugując fantomowy błąd buildu spowodowany nieaktualnymi natywnymi bindingami. Cachuj store menedżera pakietów, nie zainstalowane moduły.
Ustawiaj timeouty. Każdy job powinien mieć timeout-minutes. Domyślne to 360 minut (6 godzin). Jeśli twój deploy się zawiesi, bo połączenie SSH padło, nie chcesz odkryć tego sześć godzin później, gdy spaliłeś swój miesięczny budżet minut.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestUżywaj concurrency mądrze. Dla PR, cancel-in-progress: true jest zawsze słuszne — nikogo nie obchodzi wynik CI commita, który już został force-pushnięty. Dla deployów produkcyjnych ustaw na false. Nie chcesz, żeby szybki następny commit anulował deploy, który jest w trakcie rollout.
Testuj swój plik workflow. Użyj act (https://github.com/nektos/act) do uruchamiania workflow lokalnie. Nie złapie wszystkiego (sekrety nie są dostępne, a środowisko runnera się różni), ale łapie błędy składni YAML i oczywiste bugi logiczne zanim pushasz.
Monitoruj koszty CI. Minuty GitHub Actions są darmowe dla publicznych repozytoriów i tanie dla prywatnych, ale się sumują. Multi-platform Docker buildy to 2x minut (jeden na platformę). Macierzowe strategie testowe mnożą twój czas wykonania. Obserwuj stronę billingową.
Najlepszy pipeline CI/CD to ten, któremu ufasz. Zaufanie pochodzi z niezawodności, obserwowalności i inkrementalnego ulepszania. Zacznij od prostego pipeline'u lint-test-build. Dodaj Dockera, gdy potrzebujesz reprodukowalności. Dodaj deployment SSH, gdy potrzebujesz automatyzacji. Dodaj powiadomienia, gdy potrzebujesz pewności. Nie buduj pełnego pipeline'u w pierwszy dzień — źle dobierzesz abstrakcje.
Buduj pipeline, którego potrzebujesz dzisiaj, i pozwól mu rosnąć z twoim projektem.