İçeriğe geç
·28 dk okuma

GitHub Actions CI/CD: Gerçekten Çalışan Sıfır Kesinti Deploy'lar

Tam GitHub Actions kurulumum: paralel test job'ları, Docker build önbelleği, VPS'e SSH deploy, PM2 reload ile sıfır kesinti, secret yönetimi ve iki yılda rafine ettiğim workflow kalıpları.

Paylaş:X / TwitterLinkedIn

Üzerinde çalıştığım her proje eninde sonunda aynı kırılma noktasına ulaşır: deploy süreci elle yapmak için çok acı verici hale gelir. Testleri çalıştırmayı unutursun. Lokalde build edersin ama sürümü artırmayı unutursun. Production'a SSH yaparsın ve son deploy eden kişinin bayat bir .env dosyası bıraktığını fark edersin.

GitHub Actions bunu iki yıl önce çözdü. İlk gün mükemmel değildi — yazdığım ilk workflow zamanın yarısında zaman aşımına uğrayan ve hiçbir şeyi önbelleğe almayan 200 satırlık bir YAML kabusu oldu. Ama iterasyon iterasyon, bu siteyi güvenilir şekilde, sıfır kesintiyle, dört dakikanın altında deploy eden bir şeye ulaştım.

Bu o workflow, bölüm bölüm açıklanmış. Dokümantasyon versiyonu değil. Production ile temas ettikten sonra ayakta kalan versiyon.

Yapı Taşlarını Anlamak#

Tam pipeline'a geçmeden önce, GitHub Actions'ın nasıl çalıştığına dair net bir zihinsel modele ihtiyacın var. Jenkins veya CircleCI kullandıysan, bildiklerinin çoğunu unut. Kavramlar gevşekçe eşleşir ama yürütme modeli seni yanıltacak kadar farklı.

Tetikleyiciler: Workflow'un Ne Zaman Çalışır#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Her Pazartesi UTC 6:00
  workflow_dispatch:
    inputs:
      environment:
        description: "Hedef ortam"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

Dört tetikleyici, her biri farklı bir amaca hizmet eder:

  • push main'e yapılan production deploy tetikleyicin. Kod birleşti mi? Gönder.
  • pull_request her PR'da CI kontrollerini çalıştırır. Lint, tip kontrolü ve testler burada yaşar.
  • schedule repon için cron. Haftalık bağımlılık denetim taramaları ve bayat önbellek temizliği için kullanıyorum.
  • workflow_dispatch GitHub arayüzünde girdi parametreleriyle manuel "Deploy" düğmesi verir. Kod değişikliği olmadan staging deploy etmen gerektiğinde paha biçilmez — belki bir ortam değişkeni güncelledin veya base Docker image'ı yeniden çekmen gerekiyor.

İnsanları şaşırtan bir şey: pull_request, PR branch HEAD'i değil merge commit'e karşı çalışır. Bu, CI'ının kodu merge sonrası nasıl görüneceğini test ettiği anlamına gelir. Bu aslında istediğin şey ama yeşil bir branch'in rebase sonrası kırmızıya dönmesi insanları şaşırtıyor.

Job'lar, Step'ler ve Runner'lar#

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

Job'lar varsayılan olarak paralel çalışır. Her job taze bir VM ("runner") alır. ubuntu-latest sana makul ölçüde güçlü bir makine verir — 2026 itibarıyla 4 vCPU, 16 GB RAM. Açık repo'lar için ücretsiz, özel repo'lar için ayda 2000 dakika.

Step'ler bir job içinde sıralı çalışır. Her uses: adımı marketplace'ten yeniden kullanılabilir bir action çeker. Her run: adımı bir kabuk komutu çalıştırır.

--frozen-lockfile bayrağı kritik. Bu olmadan, pnpm install CI sırasında lockfile'ını güncelleyebilir, yani geliştiricinin commit ettiği bağımlılıklarla aynı şeyi test etmiyorsun demek. Geliştiricinin makinesindeki lockfile zaten doğru olduğu için lokalde kaybolan hayalet test başarısızlıklarına neden olduğunu gördüm.

Ortam Değişkenleri ve Secret'lar#

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"

Ortam değişkenleri workflow seviyesinde env: ile ayarlananlar düz metindir, log'larda görünür. Hassas olmayan yapılandırmalar için kullan: NODE_ENV, telemetri bayrakları, özellik toggle'ları.

Secret'lar (${{ secrets.X }}) durağan halde şifrelenir, log'larda maskelenir ve sadece aynı repo'daki workflow'lar tarafından erişilebilir. Settings > Secrets and variables > Actions altında ayarlanır.

environment: production satırı önemli. GitHub Environment'lar secret'ları belirli deploy hedeflerine kapsamlandırmana izin verir. Staging SSH anahtarın ve production SSH anahtarın ikisi de SSH_PRIVATE_KEY olarak adlandırılabilir ama job'un hangi environment'ı hedeflediğine bağlı olarak farklı değerler tutar. Bu aynı zamanda zorunlu onaylayıcıları da açar — production deploy'larını manuel onay arkasına kilitleyebilirsin.

Tam CI Pipeline#

CI pipeline'ının yarısını nasıl yapılandırdığım. Amaç: her hata kategorisini mümkün olan en kısa sürede yakalamak.

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

Bu Yapının Nedeni#

Lint, typecheck ve test paralel çalışır. Birbirlerine bağımlılıkları yok. Bir tip hatası lint'in çalışmasını engellemez ve başarısız bir test tip kontrolörünü beklemeye gerek duymaz. Tipik bir çalışmada, üçü de aynı anda çalışırken 30-60 saniyede tamamlanır.

Build üçünü de bekler. needs: [lint, typecheck, test] satırı build job'unun yalnızca lint, typecheck VE test hepsi geçerse başlayacağı anlamına gelir. Lint hataları veya tip hataları olan bir projeyi build etmenin anlamı yok.

concurrency ile cancel-in-progress: true büyük zaman tasarrufu sağlar. Hızlı peş peşe iki commit push'larsan, ilk CI çalışması iptal edilir. Bu olmadan, dakika bütçeni tüketen ve kontroller arayüzünü kalabalıklaştıran bayat çalışmaların olur.

if: always() ile kapsam yükleme testler başarısız olsa bile kapsam raporunu alacağın anlamına gelir. Bu hata ayıklama için kullanışlı — hangi testlerin başarısız olduğunu ve neyi kapsadığını görebilirsin.

Hızlı Durdur vs Hepsini Çalıştır#

Varsayılan olarak, bir matrix'teki bir job başarısız olursa GitHub diğerlerini iptal eder. CI için aslında bu davranışı istiyorum — lint başarısız olursa test sonuçlarıyla ilgilenmiyorum. Önce lint'i düzelt.

Ama test matrix'leri için (diyelim ki Node 20 ve Node 22'de test), tüm başarısızlıkları aynı anda görmek isteyebilirsin:

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 her iki matrix kolunun tamamlanmasına izin verir. Node 22 başarısız olur ama Node 20 geçerse, yeniden çalıştırmak yerine bu bilgiyi hemen görürsün.

Hız İçin Önbellekleme#

CI hızına yapabileceğin en büyük iyileştirme önbelleklemedir. Orta bir projede soğuk bir pnpm install 30-45 saniye sürer. Sıcak bir önbellekle 3-5 saniye. Bunu dört paralel job'la çarp ve her çalışmada iki dakika kazanıyorsun.

pnpm Store Önbelleği#

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

Bu tek satırlık ifade pnpm store'unu (~/.local/share/pnpm/store) önbelleğe alır. Önbellek tuttuğunda, pnpm install --frozen-lockfile indirmek yerine store'dan hard-link yapar. Bu tek başına tekrarlanan çalışmalarda kurulum süresini %80 azaltır.

Daha fazla kontrole ihtiyacın varsa — diyelim ki OS'a göre de önbelleğe almak istiyorsun — doğrudan actions/cache kullan:

yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.local/share/pnpm/store
      node_modules
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

restore-keys geri dönüşü önemli. pnpm-lock.yaml değişirse (yeni bağımlılık), tam anahtar eşleşmez ama önek eşleşmesi yine de önbelleğe alınmış paketlerin çoğunu geri yükler. Yalnızca fark indirilir.

Next.js Build Önbelleği#

Next.js'in .next/cache'te kendi build önbelleği var. Bunu çalışmalar arasında önbelleğe almak artımlı build'ler demek — yalnızca değişen sayfalar ve bileşenler yeniden derlenir.

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

Bu üç seviyeli anahtar stratejisi şu anlama gelir:

  1. Tam eşleşme: aynı bağımlılıklar VE aynı kaynak dosyalar. Tam önbellek tutması, build neredeyse anlık.
  2. Kısmi eşleşme (bağımlılıklar): bağımlılıklar aynı ama kaynak değişmiş. Build yalnızca değişen dosyaları yeniden derler.
  3. Kısmi eşleşme (yalnızca OS): bağımlılıklar değişmiş. Build yapabildiğini yeniden kullanır.

Projemden gerçek rakamlar: soğuk build ~55 saniye sürer, önbelleğe alınmış build ~15 saniye sürer. Bu %73 azalma.

Docker Katman Önbelleği#

Docker build'leri önbelleğin gerçekten etkili olduğu yerdir. Tam bir Next.js Docker build'i — OS bağımlılıklarını kurma, kaynağı kopyalama, pnpm install çalıştırma, next build çalıştırma — soğuk 3-4 dakika sürer. Katman önbellekle 30-60 saniye.

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha GitHub Actions'ın yerleşik önbellek backend'ini kullanır. mode=max yalnızca son katmanları değil tüm katmanları önbelleğe alır. Bu, ara katmanların (örneğin pnpm install) yeniden build etmenin en pahalı olduğu çok aşamalı build'ler için kritik.

Turborepo Uzak Önbelleği#

Turborepo ile bir monorepo'daysan, uzak önbellekleme dönüştürücü. İlk build görev çıktılarını önbelleğe yükler. Sonraki build'ler yeniden hesaplamak yerine indirir.

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

Turbo uzak önbellek ile monorepo CI sürelerinin 8 dakikadan 90 saniyeye düştüğünü gördüm. Dezavantajı: bir Vercel hesabı veya self-hosted Turbo sunucusu gerektirir. Tek uygulama repo'ları için aşırı.

Docker Build ve Push#

Bir VPS'e (veya herhangi bir sunucuya) deploy ediyorsan, Docker sana tekrarlanabilir build'ler verir. CI'da çalışan aynı image production'da çalışan aynı image. "Benim makinemde çalışıyor" yok çünkü makine image'ın kendisi.

Çok Aşamalı Dockerfile#

Workflow'a geçmeden önce, Next.js için kullandığım Dockerfile:

dockerfile
# Aşama 1: Bağımlılıklar
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
 
# Aşama 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
 
# Aşama 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"]

Üç aşama, net ayrım. Son image her şeyi kopyaladığında alacağın ~1.2GB yerine ~150MB. Yalnızca production artifact'ları runner aşamasına ulaşır.

Build ve Push Workflow'u#

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

Buradaki önemli kararları açıklayayım.

GitHub Container Registry (ghcr.io)#

Docker Hub yerine ghcr.io kullanıyorum, üç nedenden:

  1. Kimlik doğrulama ücretsiz. GITHUB_TOKEN her workflow'da otomatik olarak mevcut — Docker Hub kimlik bilgilerini saklamaya gerek yok.
  2. Yakınlık. Image'lar CI'ının çalıştığı aynı altyapıdan çekilir. CI sırasında çekmeler hızlı.
  3. Görünürlük. Image'lar GitHub arayüzünde repona bağlı. Packages sekmesinde görürsün.

Çoklu Platform Build'leri#

yaml
platforms: linux/amd64,linux/arm64

Bu satır build'ine belki 90 saniye ekler ama buna değer. ARM64 image'lar şunlarda native çalışır:

  • Docker Desktop ile yerel geliştirmede Apple Silicon Mac'ler (M1/M2/M3/M4)
  • AWS Graviton instance'ları (x86 eşdeğerlerinden %20-40 daha ucuz)
  • Oracle Cloud'un ücretsiz ARM katmanı

Bu olmadan, M serisi Mac'lerdeki geliştiricilerin x86 image'ları Rosetta emülasyonu üzerinden çalıştırıyor. Çalışır ama fark edilir derecede yavaş ve bazen garip mimariye özgü hatalar ortaya çıkarır.

QEMU çapraz derleme katmanını sağlar. Buildx çoklu mimari build'i yönetir ve Docker'ın otomatik olarak doğru mimariyi çekmesi için bir manifest listesi push eder.

Etiketleme Stratejisi#

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

Her image üç etiket alır:

  • abc1234 (commit SHA): Değiştirilemez. Her zaman tam bir commit'i deploy edebilirsin.
  • main (branch adı): Değiştirilebilir. O branch'ten en son build'e işaret eder.
  • latest: Değiştirilebilir. Yalnızca varsayılan branch'te ayarlanır. Sunucunun çektiği bu.

Production'da latest'i deploy ederken SHA'yı bir yere kaydetmeden asla deploy etme. Bir şey bozulduğunda hangi latest olduğunu bilmen gerekir. Deploy edilen SHA'yı health endpoint'inin okuduğu sunucudaki bir dosyaya kaydediyorum.

VPS'e SSH Deployment#

Her şeyin bir araya geldiği yer burası. CI geçer, Docker image build edilir ve push edilir, şimdi sunucuya yeni image'ı çekip yeniden başlatmasını söylememiz gerekiyor.

SSH Action#

yaml
deploy:
  name: Deploy to Production
  needs: [build-and-push]
  runs-on: ubuntu-latest
  environment: production
 
  steps:
    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.DEPLOY_HOST }}
        username: ${{ secrets.DEPLOY_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT }}
        script_stop: true
        script: |
          set -euo pipefail
 
          APP_DIR="/var/www/akousa.net"
          IMAGE="ghcr.io/${{ github.repository }}:latest"
          DEPLOY_SHA="${{ github.sha }}"
 
          echo "=== Deploying $DEPLOY_SHA ==="
 
          # En son image'ı çek
          docker pull "$IMAGE"
 
          # Eski container'ı durdur ve kaldır
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # Yeni container'ı başlat
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # Health check bekle
          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
 
          # Deploy edilen SHA'yı kaydet
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # Eski image'ları temizle
          docker image prune -af --filter "until=168h"
 
          echo "=== Deploy complete ==="

Deploy Script Alternatifi#

Basit bir çek-ve-yeniden-başlat'ın ötesinde herhangi bir şey için, mantığı workflow'a inline yazmak yerine sunucudaki bir script'e taşırım:

bash
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
 
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
 
log "Starting deployment..."
 
# GHCR'a giriş yap
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# Yeniden deneme ile çek
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 fonksiyonu
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
}
 
# Alternatif portta yeni container başlat
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# Yeni container'ın sağlıklı olduğunu doğrula
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..."
 
# Nginx upstream'i değiştir
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
 
# Eski container'ı durdur
docker stop akousa-app || true
docker rm akousa-app || true
 
# Yeni container'ı yeniden adlandır
docker rename akousa-app-new akousa-app
 
log "Deployment complete."

Workflow o zaman tek bir SSH komutu olur:

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

Bu daha iyi çünkü: (1) deploy mantığı sunucuda versiyon kontrollü, (2) hata ayıklama için SSH üzerinden elle çalıştırabilirsin ve (3) YAML içinde YAML içinde bash escape etmek zorunda kalmazsın.

Sıfır Kesinti Stratejileri#

"Sıfır kesinti" pazarlama jargonu gibi görünür ama kesin bir anlamı var: deploy sırasında hiçbir istek bağlantı reddedildi veya 502 almaz. En basitinden en sağlamına üç gerçek yaklaşım.

Strateji 1: PM2 Cluster Modu Reload#

Node.js'i doğrudan çalıştırıyorsan (Docker'da değil), PM2'nin cluster modu en kolay sıfır kesinti yolunu verir.

bash
# ecosystem.config.js zaten şunlara sahip:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (restart değil) yuvarlanan bir yeniden başlatma yapar. Yeni worker'ları başlatır, hazır olmalarını bekler, sonra eski worker'ları birer birer sonlandırır. Hiçbir noktada sıfır worker trafik sunmuyor olmaz.

--update-env bayrağı ortam değişkenlerini ecosystem config'den yeniden yükler. Bu olmadan, .env'yi değiştiren bir deploy'dan sonra bile eski env devam eder.

Workflow'unda:

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

Bu site için kullandığım bu. Basit, güvenilir ve kesinti tam olarak sıfır — deploy'lar sırasında 100 istek/s çalıştıran bir yük oluşturucu ile test ettim. Tek bir 5xx bile yok.

Strateji 2: Nginx Upstream ile Blue/Green#

Docker deployment'ları için, blue/green eski ve yeni sürümler arasında temiz bir ayrım verir.

Kavram: eski container'ı ("blue") port 3000'de ve yeni container'ı ("green") port 3001'de çalıştır. Nginx blue'ya işaret eder. Green'i başlat, sağlıklı olduğunu doğrula, Nginx'i green'e geçir, sonra blue'yu durdur.

Nginx upstream yapılandırması:

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

Geçiş script'i:

bash
#!/bin/bash
set -euo pipefail
 
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
 
if [ "$CURRENT_PORT" = "3000" ]; then
  NEW_PORT=3001
  OLD_PORT=3000
else
  NEW_PORT=3000
  OLD_PORT=3001
fi
 
echo "Current: $OLD_PORT -> New: $NEW_PORT"
 
# Alternatif portta yeni container başlat
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"
 
# Sağlık kontrolü bekle
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "New container healthy on port $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# Nginx'i geçir
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
 
# Eski container'ı durdur
sleep 5  # Havadaki isteklerin tamamlanması için
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Switched from :$OLD_PORT to :$NEW_PORT"

Nginx reload sonrası 5 saniyelik bekleme tembellik değil — tolerans süresi. Nginx'in reload'u graceful (mevcut bağlantılar açık tutulur) ama bazı long-polling bağlantıları veya streaming yanıtlarının tamamlanması için zamana ihtiyacı var.

Strateji 3: Health Check'li Docker Compose#

Daha yapılandırılmış bir yaklaşım için Docker Compose blue/green geçişini yönetebilir:

yaml
# docker-compose.yml
services:
  app:
    image: ghcr.io/akousa/akousa-net:latest
    restart: unless-stopped
    env_file: .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    ports:
      - "3000:3000"

order: start-first anahtar satır. "Eskisini durdurmadan önce yeni container'ı başlat" anlamına gelir. parallelism: 1 ile birleştiğinde, yuvarlanan bir güncelleme elde edersin — her seferinde bir container, her zaman kapasite koruyarak.

Deploy şöyle yapılır:

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

Docker Compose healthcheck'i izler ve yeni container geçene kadar ona trafik yönlendirmez. Healthcheck başarısız olursa, failure_action: rollback otomatik olarak önceki sürüme geri döner. Bu, tek bir VPS'te Kubernetes tarzı yuvarlanan deployment'lara en yakın şey.

Secret Yönetimi#

Secret yönetimi "çoğunlukla doğru" yapması kolay ve kalan uç durumlarda felaket derecesinde yanlış yapılabilen şeylerden biri.

GitHub Secret'ları: Temeller#

yaml
# GitHub arayüzünden ayarlanır: Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # Değer log'larda maskelenir
      echo "Connecting to database..."
      # Bu log'larda "Connecting to ***" yazdırır
      echo "Connecting to $DB_URL"

GitHub secret değerlerini log çıktısından otomatik olarak maskeler. Secret'ın p@ssw0rd123 ise ve herhangi bir adım o string'i yazdırırsa, log'lar *** gösterir. Bu iyi çalışır, bir istisna dışında: secret'ın kısa ise (4 haneli bir PIN gibi), GitHub masum string'lerle eşleşebileceği için maskelemeyebilir. Secret'ları makul ölçüde karmaşık tut.

Environment Kapsamlı Secret'lar#

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

Aynı secret adı, environment başına farklı değerler. Job'daki environment alanı hangi secret setinin enjekte edileceğini belirler.

Production environment'larında zorunlu onaylayıcılar etkin olmalı. Bu, main'e push'un workflow'u tetiklediği, CI'ın otomatik çalıştığı ama deploy job'unun durup GitHub arayüzünde birinin "Approve" tıklamasını beklediği anlamına gelir. Solo bir proje için bu gereksiz yük gibi hissedebilir. Kullanıcıları olan herhangi bir şey için, yanlışlıkla bozuk bir şeyi merge ettiğin ilk sefer hayat kurtarıcı.

OIDC: Artık Statik Kimlik Bilgisi Yok#

GitHub Secrets'ta saklanan statik kimlik bilgileri (AWS erişim anahtarları, GCP servis hesabı JSON dosyaları) bir yükümlülük. Süreleri dolmaz, belirli bir workflow çalışmasına kapsam belirlenemez ve sızarlarsa elle döndürmen gerekir.

OIDC (OpenID Connect) bunu çözer. GitHub Actions bir kimlik sağlayıcı olarak davranır ve bulut sağlayıcın anında kısa ömürlü kimlik bilgileri vermesi için ona güvenir:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # OIDC için gerekli
      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

Erişim anahtarı yok. Gizli anahtar yok. configure-aws-credentials action'ı GitHub'ın OIDC token'ını kullanarak AWS STS'den geçici bir token ister. Token belirli repo, branch ve environment'a kapsamlıdır. Workflow çalışması sonrası süresi dolar.

AWS tarafında bunu ayarlamak bir IAM OIDC kimlik sağlayıcı ve bir rol güven politikası gerektirir:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
        }
      }
    }
  ]
}

sub koşulu kritik. Bu olmadan, OIDC sağlayıcının ayrıntılarını bir şekilde elde eden herhangi bir repo rolü üstlenebilir. Bununla, yalnızca belirli reponun main branch'i bunu yapabilir.

GCP'de Workload Identity Federation ile eşdeğer bir kurulum var. Azure'da federe kimlik bilgileri var. Bulut sağlayıcın OIDC destekliyorsa kullan. 2026'da statik bulut kimlik bilgilerini saklamak için bir neden yok.

Deploy SSH Anahtarları#

VPS deployment'ları için SSH üzerinden özel bir anahtar çifti oluştur:

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

Genel anahtarı sunucunun ~/.ssh/authorized_keys dosyasına kısıtlamalarla ekle:

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

restrict öneki port yönlendirme, agent yönlendirme, PTY tahsisi ve X11 yönlendirmesini devre dışı bırakır. command= öneki bu anahtarın yalnızca deploy script'ini çalıştırabileceği anlamına gelir. Özel anahtar ele geçirilse bile, saldırgan deploy script'ini çalıştırabilir ve başka hiçbir şey yapamaz.

Özel anahtarı GitHub Secrets'a SSH_PRIVATE_KEY olarak ekle. Bu kabul ettiğim tek statik kimlik bilgisi — zorunlu komutlu SSH anahtarlarının patlama yarıçapı çok sınırlı.

PR Workflow'ları: Önizleme Deployment'ları#

Her PR bir önizleme ortamını hak eder. Unit testlerin kaçırdığı görsel hataları yakalar, tasarımcıların kodu checkout etmeden incelemesine izin verir ve QA'nın hayatını dramatik olarak kolaylaştırır.

PR Açıldığında Önizleme Deploy Et#

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 }}\`_`;
 
            // Mevcut yorumu bul
            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,
              });
            }

Port hesaplama (4000 + PR_NUM) pragmatik bir çözüm. PR #42 port 4042 alır. Birkaç yüzden fazla açık PR'ın olmadığı sürece çakışma olmaz. Bir Nginx wildcard yapılandırması pr-*.preview.akousa.net'i doğru porta yönlendirir.

PR Kapandığında Temizlik#

Temizlenmeyen önizleme ortamları disk ve bellek yer. Temizlik job'u ekle:

yaml
name: Cleanup Preview
 
on:
  pull_request:
    types: [closed]
 
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview container
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
            docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
            echo "Preview for PR #${PR_NUM} cleaned up."
 
      - name: Deactivate environment
        uses: actions/github-script@v7
        with:
          script: |
            const deployments = await github.rest.repos.listDeployments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              environment: `preview-${{ github.event.number }}`,
            });
 
            for (const deployment of deployments.data) {
              await github.rest.repos.createDeploymentStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                deployment_id: deployment.id,
                state: 'inactive',
              });
            }

Zorunlu Durum Kontrolleri#

Repository ayarlarında (Settings > Branches > Branch protection rules), merge öncesi bu kontrolleri zorunlu kıl:

  • lint — Lint hatası yok
  • typecheck — Tip hatası yok
  • test — Tüm testler geçiyor
  • build — Proje başarıyla build ediliyor

Bu olmadan, birisi başarısız kontrollerle bir PR merge edecek. Kötü niyetle değil — "4 kontrolden 2'si geçti" görecek ve diğer ikisinin hâlâ çalıştığını varsayacak. Kilitle.

Ayrıca "Require branches to be up to date before merging" seçeneğini etkinleştir. Bu, en son main üzerine rebase sonrası CI'ın yeniden çalışmasını zorlar. İki PR'ın ayrı ayrı CI'ı geçip birleştirildiğinde çakıştığı durumu yakalar.

Bildirimler#

Kimsenin bilmediği bir deployment, kimsenin güvenmediği bir deployment'tır. Bildirimler geri bildirim döngüsünü kapatır.

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() kritik. Bu olmadan, deploy başarısız olduğunda bildirim adımı atlanır — ki tam da en çok ihtiyacın olduğu an.

GitHub Deployments API#

Daha zengin deployment takibi için GitHub Deployments API'ını kullan. Bu repo arayüzünde deployment geçmişi ve durum rozetleri sağlar:

yaml
- name: Create GitHub Deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: context.sha,
        environment: 'production',
        auto_merge: false,
        required_contexts: [],
        description: `Deploying ${context.sha.substring(0, 7)} to production`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... gerçek deployment adımları ...
 
- name: Update deployment status
  if: always()
  uses: actions/github-script@v7
  with:
    script: |
      const deploymentId = ${{ steps.deployment.outputs.result }};
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: deploymentId,
        state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
        environment_url: 'https://akousa.net',
        log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
        description: '${{ job.status }}' === 'success'
          ? 'Deployment succeeded'
          : 'Deployment failed',
      });

Artık GitHub'daki Environments sekmesi tam bir deployment geçmişi gösterir: kim neyi, ne zaman deploy etti ve başarılı olup olmadığı.

Yalnızca Başarısızlık E-postası#

Kritik deployment'lar için başarısızlıkta e-posta da tetiklerim. GitHub Actions'ın yerleşik e-postasıyla değil (çok gürültülü), hedefli bir webhook ile:

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

Bu son savunma hattım. Slack harika ama aynı zamanda gürültülü — insanlar kanalları sessize alıyor. Çalışma bağlantısı içeren bir "DEPLOY FAILED" e-postası dikkat çeker.

Tam Workflow Dosyası#

Her şeyin tek bir production-ready workflow'a bağlandığı hali. Bu, bu siteyi gerçekten deploy eden şeye çok yakın.

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, tip kontrolü ve test paralel
  # ============================================================
 
  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: Yalnızca CI geçtikten sonra
  # ============================================================
 
  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: Image build ve push (yalnızca main branch)
  # ============================================================
 
  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: VPS'e SSH ile bağlan ve güncelle
  # ============================================================
 
  deploy:
    name: Deploy to Production
    needs: [docker]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://akousa.net
 
    steps:
      - name: Create GitHub Deployment
        id: deployment
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: 'production',
              auto_merge: false,
              required_contexts: [],
              description: `Deploy ${context.sha.substring(0, 7)}`,
            });
            return deployment.data.id;
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          command_timeout: 5m
          script: |
            set -euo pipefail
 
            APP_DIR="/var/www/akousa.net"
            IMAGE="ghcr.io/${{ github.repository }}:latest"
            SHA="${{ github.sha }}"
 
            echo "=== Deploy $SHA started at $(date) ==="
 
            # Yeni image'ı çek
            docker pull "$IMAGE"
 
            # Alternatif portta yeni container çalıştır
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # Sağlık kontrolü
            echo "Running health check..."
            for i in $(seq 1 30); do
              if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
                echo "Health check passed (attempt $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "ERROR: Health check failed"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # Trafiği geçir
            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
 
            # Havadaki istekler için tolerans süresi
            sleep 5
 
            # Eski container'ı durdur
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # Yeniden adlandır ve portu sıfırla
            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
            # Not: Burada Nginx'i yeniden yüklemiyoruz çünkü container adı değişti,
            # port değil. Sonraki deploy doğru portu kullanacak.
 
            # Deployment'ı kaydet
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # Eski image'ları temizle (7 günden eski)
            docker image prune -af --filter "until=168h"
 
            echo "=== Deploy complete at $(date) ==="
 
      - name: Update deployment status
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const deploymentId = ${{ steps.deployment.outputs.result }};
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deploymentId,
              state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
              environment_url: 'https://akousa.net',
              log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });
 
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Actor:*\n${{ github.actor }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "View Run" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
 
      - name: Alert on failure
        if: failure()
        run: |
          curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{
              "subject": "DEPLOY FAILED: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

Akışı Takip Etmek#

main'e push ettiğimde:

  1. Lint, Tip Kontrolü ve Test aynı anda başlar. Üç runner, üç paralel job. Herhangi biri başarısız olursa pipeline durur.
  2. Build yalnızca üçü de geçerse çalışır. Uygulamanın derlenebildiğini ve çalışan çıktı ürettiğini doğrular.
  3. Docker production image'ı build eder ve ghcr.io'ya push eder. Çoklu platform, katman önbellekli.
  4. Deploy VPS'e SSH yapar, yeni image'ı çeker, yeni container başlatır, health-check yapar, Nginx'i geçirir ve temizlik yapar.
  5. Bildirimler sonuçtan bağımsız olarak tetiklenir. Slack mesajı alır. GitHub Deployments güncellenir. Başarısız olduysa uyarı e-postası gider.

PR açtığımda:

  1. Lint, Tip Kontrolü ve Test çalışır. Aynı kalite kapıları.
  2. Build projenin derlendiğini doğrulamak için çalışır.
  3. Docker ve Deploy atlanır (if koşulları bunları yalnızca main branch'e kapılar).

Acil deploy'a ihtiyacım olduğunda (testleri atla):

  1. Actions sekmesinde "Run workflow" tıkla.
  2. skip_tests: true seç.
  3. Lint ve typecheck hâlâ çalışır (bunları atlayamazsın — kendime o kadar güvenmiyorum).
  4. Testler atlanır, build çalışır, Docker build'i yapılır, deploy tetiklenir.

Bu iki yıldır benim workflow'um. Sunucu geçişlerinden, Node.js major sürüm yükseltmelerinden, npm'in yerini pnpm'in almasından ve bu siteye 15 aracın eklenmesinden sağ çıktı. Push'tan production'a toplam uçtan uca süre: ortalama 3 dakika 40 saniye. En yavaş adım ~90 saniyeyle çoklu platform Docker build'i. Geri kalan her şey neredeyse anlık olacak şekilde önbelleğe alınmış.

İki Yıllık İterasyondan Dersler#

Sen yapmak zorunda kalmayasın diye yaptığım hatalarla kapatıyorum.

Action sürümlerini sabitle. uses: actions/checkout@v4 iyi ama production için uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (tam SHA) düşün. Ele geçirilmiş bir action secret'larını sızdırabilir. 2025'teki tj-actions/changed-files olayı bunun teorik olmadığını kanıtladı.

Her şeyi önbelleğe alma. Bir keresinde node_modules'u doğrudan (sadece pnpm store'u değil) önbelleğe aldım ve bayat native binding'lerin neden olduğu hayalet bir build hatası ayıklarken iki saat harcadım. Paket yöneticisi store'unu önbelleğe al, kurulu modülleri değil.

Zaman aşımı ayarla. Her job'un timeout-minutes'ı olmalı. Varsayılan 360 dakika (6 saat). SSH bağlantısı koptuğu için deploy'un takılırsa, aylık dakikalarını tükettiğinde altı saat sonra keşfetmek istemezsin.

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

concurrency'yi akıllıca kullan. PR'lar için cancel-in-progress: true her zaman doğru — zaten force-push ile üzerine yazılmış bir commit'in CI sonucuyla kimse ilgilenmez. Production deploy'ları için false olarak ayarla. Hızla takip eden bir commit'in yarıda kalan bir deploy'u iptal etmesini istemezsin.

Workflow dosyanı test et. Workflow'ları lokalde çalıştırmak için act (https://github.com/nektos/act) kullan. Her şeyi yakalamaz (secret'lar mevcut değil ve runner ortamı farklı) ama push etmeden önce YAML sözdizimi hatalarını ve bariz mantık hatalarını yakalar.

CI maliyetlerini izle. GitHub Actions dakikaları açık repo'lar için ücretsiz ve özel repo'lar için ucuz ama toplanır. Çoklu platform Docker build'leri 2x dakika (platform başına bir). Matrix test stratejileri çalışma süresini çarpıyor. Faturalama sayfasına göz at.

En iyi CI/CD pipeline'ı güvendiğin pipeline'dır. Güven güvenilirlik, gözlemlenebilirlik ve artımlı iyileştirmeden gelir. Basit bir lint-test-build pipeline'ıyla başla. Tekrarlanabilirliğe ihtiyacın olduğunda Docker ekle. Otomasyona ihtiyacın olduğunda SSH deployment ekle. Güvene ihtiyacın olduğunda bildirimler ekle. İlk günden tam pipeline'ı kurma — soyutlamaları yanlış yaparsın.

Bugün ihtiyacın olan pipeline'ı kur ve projenle birlikte büyümesine izin ver.

İlgili Yazılar