Kod deposuna her push'ta testleri elle çalıştırmak, imajı elle derleyip sunucuya elle atmak — bu döngü ilk hafta zahmetsiz görünür, üçüncü ayda ise unutulan bir adım yüzünden yayına eksik dosya çıkar. GitHub Actions ile CI/CD kurulumu tam olarak bu insan hatasını devre dışı bırakmak için var: depoya bir YAML dosyası koyarsın, GitHub her push'ta o dosyadaki adımları temiz bir makinede çalıştırır.
Bu rehberde sıfırdan çalışan bir hat kuracağız. Önce workflow'un yapı taşlarını netleştireceğim, sonra test çalıştıran ilk dosyayı yazacağız, bağımlılık önbelleğiyle süreyi kısaltacağız, secrets yönetimini doğru kuracağız, Docker imajını derleyip bir kayıt defterine göndereceğiz ve son adımda sunucuya otomatik dağıtım yapacağız. Sonunda da üretimde en çok kan kaybettiren hataları tek tek gözden geçireceğiz — hattın çalışması kadar, yanlış çalışmaması da önemli.
Workflow'un Yapı Taşları#
GitHub Actions'ta dört kavram var ve bu dördü karıştırmadığın sürece geri kalanı okuma-yazma işi. Workflow, .github/workflows/ dizinindeki bir YAML dosyasıdır ve bir olayla tetiklenir. Job, workflow içindeki bir iş birimidir; kendi sanal makinesinde (runner) çalışır. Step, bir job içindeki tek adımdır; ya bir kabuk komutu ya hazır bir action'dır. Action ise başkasının yazdığı, uses: ile çağırdığın yeniden kullanılabilir adımdır.
En kritik ayrıntı şu: her job ayrı bir makinede, sıfırdan başlar. Bir job'da kurduğun paket, diğer job'da yoktur. Dosya paylaşmak istiyorsan actions/upload-artifact ve actions/download-artifact ile taşımak zorundasın. Bu kural bir kez oturmadan yazılan workflow'lar, "yerelde çalışıyor ama CI'da yok" hatalarının kaynağıdır.
| Kavram | Nerede tanımlanır | Örnek |
|---|---|---|
| Workflow | .github/workflows/ci.yml | Push'ta testleri çalıştır |
Trigger (on) | Workflow başında | push, pull_request, schedule |
| Job | jobs: altında | test, build, deploy |
| Runner | runs-on: | ubuntu-latest, kendi sunucun |
| Step | steps: altında | npm ci, docker build |
| Action | uses: ile | actions/checkout@v5 |
Job'lar varsayılan olarak paralel çalışır. Sıra istiyorsan needs: ile bağımlılık kurarsın; deploy job'u needs: [test, build] yazıyorsa yalnızca ikisi de başarılı olduğunda başlar.
İlk Workflow Dosyası#
Deponun kökünde .github/workflows/ci.yml dosyasını oluştur. Aşağıdaki örnek bir Node.js projesi için testleri çalıştırır ama mantık her dil için aynıdır:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Depoyu çek
uses: actions/checkout@v5
- name: Node kurulumu
uses: actions/setup-node@v5
with:
node-version: "22"
cache: npm # bağımlılık önbelleğini açar
- name: Bağımlılıkları kur
run: npm ci # npm install DEĞİL: kilit dosyasına birebir uyar
- name: Lint
run: npm run lint
- name: Testler
run: npm test
Dosyayı push ettiğin anda GitHub deponun Actions sekmesinde çalışmayı gösterir. npm ci ile npm install arasındaki farkı vurgulamak istiyorum: npm ci kilit dosyasıyla package.json çeliştiğinde hata verir, npm install ise sessizce yeniden çözer. CI'da istediğin davranış birincisidir; ikincisi, yerelde test ettiğin sürümden farklı bir bağımlılığın üretime çıkmasına izin verir.
Aynı testi birden fazla sürümde çalıştırmak istiyorsan matris kullanabilirsin:
strategy:
fail-fast: false # biri patlayınca diğerlerini iptal etme
matrix:
node: ["20", "22"]
steps:
- uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node }}
Önbellek ile Süreyi Kısaltmak#
Her çalışmada bağımlılıkları sıfırdan indirmek, tipik bir projede toplam sürenin yarısından fazlasını yiyebilir. actions/setup-node, setup-python ve setup-java gibi kurulum action'ları cache: parametresiyle bu işi kendiliğinden yapar. Daha karmaşık durumlarda actions/cache ile kendi anahtarını tanımlarsın:
- name: Derleme önbelleği
uses: actions/cache@v5
with:
path: |
~/.cache/pip
.venv
# Anahtar kilit dosyasının içeriğine bağlı: dosya değişince önbellek yenilenir
key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-
Buradaki mantık iki katmanlıdır. key tam eşleşme arar; requirements.txt değişmediyse önbellek birebir geri yüklenir. Eşleşme yoksa restore-keys öneki en yakın eski önbelleği getirir ve sen sadece değişen paketleri indirirsin. restore-keys yazmayı unutmak, en sık görülen "önbellek neden hiç isabet etmiyor" sebebidir.
Docker imajı derliyorsan katman önbelleği ayrı bir konudur; Buildx'in GitHub Actions önbelleğini kullanması gerekir:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: false
tags: firmaniz/api:test
cache-from: type=gha
cache-to: type=gha,mode=max
Önbelleğin gerçekten işe yaraması için Dockerfile'ın katman sırasının doğru olması gerekir; bağımlılıkları kaynak kodundan önce kopyalamak gibi kurallar için Dockerfile en iyi pratikler ve imajı küçültme yöntemleri için Docker imaj boyutu optimizasyonu yazılarına bakabilirsin.
Secrets ve Ortam Değişkenleri#
Şifreleri, API anahtarlarını ve SSH anahtarlarını asla workflow dosyasına yazma; o dosya depoda duruyor ve depoyu gören herkes okuyor. GitHub'ın secret deposu bu iş için var: depo ayarlarında Settings → Secrets and variables → Actions yolundan tanımlarsın ve workflow'da ifade sözdizimiyle çağırırsın.
- name: Kayıt defterine giriş
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Uygulama ayarları
env:
DB_SIFRE: ${{ secrets.DB_SIFRE }}
API_ANAHTARI: ${{ secrets.API_ANAHTARI }}
run: ./scripts/migrate.sh
Bilmen gereken üç davranış var. Birincisi, GitHub secret değerlerini log çıktısında otomatik maskeler — ama yalnızca birebir eşleşmeyi. Bir secret'ı base64'e çevirip yazdırırsan maske çalışmaz. İkincisi, GITHUB_TOKEN her çalışmada otomatik üretilen geçici bir token'dır ve iş bitince geçersiz olur; yetkisini permissions: bloğuyla daraltmalısın:
permissions:
contents: read # varsayılanı daralt
packages: write # yalnızca imaj göndermek için
Üçüncüsü ve en önemlisi: pull_request_target tetikleyicisi ile secret'a dokunan bir job yazma. Bu tetikleyici, dış katkıcıdan gelen kodu depo yetkileriyle çalıştırır ve kötü niyetli bir PR secret'larını sızdırabilir. Dış katkı alan depolarda pull_request kullan; o tetikleyicide secret'lar zaten paylaşılmaz. Sızdırılan kimlik bilgilerinin ne kadar ucuza istismar edildiğini görmek istersen .git klasörü ve .env dosyası ifşası yazısındaki örnekler fikir verir.
Docker İmajı Derleyip Göndermek#
CI'ın ikinci ayağı, testten geçen kodu dağıtılabilir bir artefakta dönüştürmektir. GitHub'ın kendi kayıt defteri ghcr.io bu iş için ek hesap açmayı gerektirmez:
build:
needs: test # testler geçmeden derleme başlamaz
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: İmajı derle ve gönder
uses: docker/build-push-action@v6
with:
context: .
push: true
# İki etiket: değişmez commit sha'sı ve hareketli latest
tags: |
ghcr.io/firmaniz/api:${{ github.sha }}
ghcr.io/firmaniz/api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Buradaki en değerli alışkanlık, imajı commit sha'sıyla etiketlemektir. latest etiketi hareketli bir hedeftir; hangi kodun yayında olduğunu sana asla söylemez ve geri alma (rollback) yaparken elinde tutunacak bir sürüm kalmaz. Sha etiketiyle her dağıtımın hangi commit'ten geldiği kesindir ve bir önceki sürüme dönmek tek komuttur. Kayıt defterinden imaj çekerken karşılaşabileceğin oran sınırı sorunları için Docker Hub pull limiti hatası yazısı işine yarar.
Sunucuya Otomatik Dağıtım#
Son halka, derlenen imajı sunucuda çalıştırmaktır. En sade yöntem SSH ile bağlanıp Compose yığınını güncellemektir. Önce sunucuda yalnızca dağıtım için ayrı bir kullanıcı ve anahtar çifti oluştur, anahtarın özel kısmını SSH_ANAHTARI adıyla secret olarak kaydet:
deploy:
needs: build
runs-on: ubuntu-latest
# Yalnızca main dalına push olduğunda çalış
if: github.ref == 'refs/heads/main'
steps:
- name: SSH anahtarını hazırla
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_ANAHTARI }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
# Sunucunun parmak izini önceden ekle (MITM'e karşı)
ssh-keyscan -H 185.12.34.56 >> ~/.ssh/known_hosts
- name: Yığını güncelle
run: |
ssh -i ~/.ssh/id_ed25519 [email protected] \
"cd /srv/uygulama && \
docker compose pull && \
docker compose up -d --remove-orphans && \
docker image prune -f"
ssh-keyscan satırını atlayıp StrictHostKeyChecking=no yazmak yaygın ama zayıf bir kısayoldur; sunucunun kimliğini doğrulamadan bağlanırsın. Ayrıca docker image prune -f adımı önemlidir: her dağıtımda yeni bir imaj çekilir ve eskiler diskte birikir. Bu birikimin nasıl bir soruna dönüştüğünü Docker diski doldurdu, nasıl temizlenir yazısında ayrıntılı anlattım.
Dağıtım hedefin bir Kubernetes kümesiyse SSH yerine kubectl set image ya da bir GitOps aracı kullanırsın; bu durumda CI hesabının yetkisini namespace ve RBAC yazısındaki gibi tek namespace'e daraltmayı unutma.
Sık Yapılan Hatalar ve Tuzaklar#
Her job'un temiz makinede başladığını unutmak. test job'unda kurduğun bağımlılık, deploy job'unda yoktur. Dosya taşımak için artifact kullan, kurulum adımlarını her job'da tekrarla.
Action sürümlerini sabitlememek. uses: actions/checkout@v5 gibi majör sürüm sabitlemesi makul bir dengedir; @main yazmak ise başkasının deposundaki her değişikliği doğrudan hattına almak demektir. Yüksek güvenlik gereken depolarda action'ları commit sha'sıyla sabitlemek en güvenlisidir.
Deploy job'una dal koşulu koymamak. if: github.ref == 'refs/heads/main' satırı yoksa her özellik dalı üretime dağıtım yapar. Bu, fark edilmesi en geç ve etkisi en büyük hatalardan biridir.
Zaman aşımı tanımlamamak. Takılan bir test veya cevap vermeyen bir servis, job'u dakikalarca boşuna çalıştırır. Her workflow'a timeout-minutes: 15 gibi bir tavan koy.
Concurrency ayarını atlamak. Arka arkaya iki push yaparsan iki dağıtım aynı anda çalışır ve sonuçları karışabilir. Aşağıdaki blok, aynı dal için önceki çalışmayı iptal ederek bunu engeller:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Log'a gizli veri basmak. Hata ayıklarken env çıktısını tamamen yazdırmak cazip gelir ama maskeleme her biçimi yakalayamaz. Gerektiğinde tek tek, maskelenmiş değişkenleri kontrol et.
Sıkça Sorulan Sorular#
GitHub Actions ücretsiz mi#
Genel (public) depolarda GitHub tarafından barındırılan runner'lar ücretsizdir. Özel depolarda ise hesap planına bağlı aylık bir dakika kotası verilir; kota aşıldığında kullanım ücretlendirilir. Kendi sunucunu self-hosted runner olarak bağlarsan GitHub tarafındaki dakika tüketimi ortadan kalkar, karşılığında runner'ın bakımı ve güvenliği sana ait olur.
Workflow neden hiç çalışmıyor#
En sık üç sebep vardır. Birincisi dosyanın yanlış yerde olmasıdır; yol tam olarak .github/workflows/dosya.yml şeklinde olmalıdır. İkincisi YAML söz dizimi hatasıdır — GitHub bu durumda Actions sekmesinde bir uyarı gösterir. Üçüncüsü tetikleyicinin eşleşmemesidir; branches: [main] yazılmışken master dalına push ediyorsan hiçbir şey çalışmaz.
Secrets değerlerini loglarda görebilir miyim#
Hayır ve görmeye çalışmamalısın. GitHub secret değerlerinin birebir eşleşmelerini log çıktısında yıldızlarla maskeler. Ancak değeri dönüştürerek (base64, ters çevirme, parçalara bölme) yazdırırsan maskeleme çalışmaz ve secret log'da açıkta kalır. Hata ayıklarken değerin uzunluğunu yazdırmak ya da bir sağlama toplamı karşılaştırmak güvenli alternatiflerdir.
Self-hosted runner kullanmalı mıyım#
Derlemelerin uzun sürüyorsa, özel ağdaki bir sunucuya erişmen gerekiyorsa veya GitHub dakika kotasını aşıyorsan mantıklıdır. Buna karşılık runner'ı sen güncellersin, diskini sen temizlersin ve en önemlisi izole edersin: genel bir depoda self-hosted runner kullanmak, dış katkıcının kodunu kendi sunucunda çalıştırmak anlamına gelir ve ciddi bir risktir.
GitLab CI yerine neden GitHub Actions#
Kodun zaten GitHub'daysa Actions en az sürtünmeli seçenektir; ayrı bir sistem kurmaz, yetkilendirme deponun kendisinden gelir. Kodun GitLab'daysa GitLab CI pipeline doğal tercihtir. Kendi altyapında kalmak istiyorsan Drone CI veya Woodpecker CI gibi hafif alternatifler kendi sunucunda çalışır.
Dağıtım başarısız olursa geri alabilir miyim#
İmajları commit sha'sıyla etiketlediysen evet, tek komutla önceki sürüme dönebilirsin: sunucuda docker compose dosyasındaki etiketi bir önceki sha ile değiştirip yeniden çalıştırmak yeterlidir. latest etiketiyle çalışıyorsan geri dönecek bir referansın olmadığı için önce eski imajı yeniden derlemen gerekir; bu yüzden sha etiketi bir tercih değil, bir sigortadır.
Kapanış#
Sağlam bir GitHub Actions hattı, birkaç alışkanlığın toplamıdır: npm ci benzeri kilit dosyasına sadık kurulum komutları kullan, bağımlılıkları restore-keys ile birlikte önbellekle, imajları commit sha'sıyla etiketle, deploy job'una mutlaka bir dal koşulu ve concurrency bloğu ekle. Bu dördü yerindeyse hattın hem hızlı hem de öngörülebilir olur; en önemlisi, hangi kodun yayında olduğunu her an bilirsin.
Dağıtımın karşı ucundaki sunucu da bu hattın parçası. Docker yığınını çalıştıracağın tam root erişimli VDS ve bulut sunucu paketlerimiz otomatik dağıtım için uygun bir hedef sunar; sunucunun güncellemeleri, güvenlik sıkılaştırması ve izlenmesiyle uğraşmak istemiyorsan sunucu yönetimi hizmetimizle bu tarafı bize bırakabilirsin. Dağıtım kullanıcısı için güçlü bir parola üretmek istersen şifre üretici aracımız hazır.