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ı.
Ü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#
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
- productionDört tetikleyici, her biri farklı bir amaca hizmet eder:
pushmain'e yapılan production deploy tetikleyicin. Kod birleşti mi? Gönder.pull_requesther PR'da CI kontrollerini çalıştırır. Lint, tip kontrolü ve testler burada yaşar.schedulerepon için cron. Haftalık bağımlılık denetim taramaları ve bayat önbellek temizliği için kullanıyorum.workflow_dispatchGitHub 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#
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 lintJob'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#
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.
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: 1Bu 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:
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 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#
- 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:
- 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.
- 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:
- Tam eşleşme: aynı bağımlılıklar VE aynı kaynak dosyalar. Tam önbellek tutması, build neredeyse anlık.
- 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.
- 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.
- 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 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.
- 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:
# 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#
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=maxBuradaki önemli kararları açıklayayım.
GitHub Container Registry (ghcr.io)#
Docker Hub yerine ghcr.io kullanıyorum, üç nedenden:
- Kimlik doğrulama ücretsiz.
GITHUB_TOKENher workflow'da otomatik olarak mevcut — Docker Hub kimlik bilgilerini saklamaya gerek yok. - Yakınlık. Image'lar CI'ının çalıştığı aynı altyapıdan çekilir. CI sırasında çekmeler hızlı.
- Görünürlük. Image'lar GitHub arayüzünde repona bağlı. Packages sekmesinde görürsün.
Çoklu Platform Build'leri#
platforms: linux/amd64,linux/arm64Bu 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#
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#
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:
#!/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:
script: |
cd /var/www/akousa.net && ./deploy.shBu 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.
# ecosystem.config.js zaten şunlara sahip:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 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:
- 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-envBu 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ı:
# /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;
}
}Geçiş script'i:
#!/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:
# 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:
docker compose pull
docker compose up -d --remove-orphansDocker 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#
# 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#
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.netAynı 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:
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.comEriş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:
{
"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:
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#
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:
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ı yoktypecheck— Tip hatası yoktest— Tüm testler geçiyorbuild— 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#
- 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:
- 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:
- 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.
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 }}"
}' || trueAkışı Takip Etmek#
main'e push ettiğimde:
- Lint, Tip Kontrolü ve Test aynı anda başlar. Üç runner, üç paralel job. Herhangi biri başarısız olursa pipeline durur.
- Build yalnızca üçü de geçerse çalışır. Uygulamanın derlenebildiğini ve çalışan çıktı ürettiğini doğrular.
- Docker production image'ı build eder ve ghcr.io'ya push eder. Çoklu platform, katman önbellekli.
- Deploy VPS'e SSH yapar, yeni image'ı çeker, yeni container başlatır, health-check yapar, Nginx'i geçirir ve temizlik yapar.
- 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:
- Lint, Tip Kontrolü ve Test çalışır. Aynı kalite kapıları.
- Build projenin derlendiğini doğrulamak için çalışır.
- Docker ve Deploy atlanır (
ifkoşulları bunları yalnızcamainbranch'e kapılar).
Acil deploy'a ihtiyacım olduğunda (testleri atla):
- Actions sekmesinde "Run workflow" tıkla.
skip_tests: trueseç.- Lint ve typecheck hâlâ çalışır (bunları atlayamazsın — kendime o kadar güvenmiyorum).
- 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.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestconcurrency'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.