GitHub Actions ile Terraform: OIDC ile Plan/Apply, PR'a Plan Özeti, Onaylı Apply ve Drift Kontrolü
Güvenli bir Terraform pipeline'ında PR'daki plan review içindir; main'de plan yeniden alınıp kaydedilir ve onaydan sonra tam olarak o dosya uygulanır, plan ve apply da OIDC ile ayrı identity'lerle çalışır. Kontrol, plan özeti yorumu, environment onaylı apply ve concurrency içeren workflow'ları yazıyor, fork PR'larını credential'dan ayırıyor ve check block'ları ile provider gürültüsüne takılmayan bir drift script'ini gerçek denemelerle kuruyoruz.
Sürüm notu: Bu yazıdaki workflow'lar actionlint 1.7.12 ile doğrulandı; içlerinde çalışan script'ler Terraform 1.17.2, TFLint 0.66.0, Node.js 24, kreuzwerker/docker provider 4.7.0 ve Docker Engine 29 ile lokal olarak denendi. Workflow'lar GitHub'da koşulmadı; OIDC (OpenID Connect; pipeline'ın secret saklamadan kısa ömürlü token'la kimlik doğrulaması), environment onayı ve Azure'a plan/apply yazıya dokümana dayanarak girdi.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) altyapı kodu için bir pipeline kuruyorsun. Pull request açılınca plan alınıyor ve ekip plan'ı okuyup onaylıyor. Merge'den sonra apply job'u çalışıyor. Apply job'u, PR (pull request; kodun birleştirilmeden önce incelendiği istek) sırasında okunan plan'ı mı uygular, yoksa o anda yeni bir plan mı alır? İkisi arasında başka bir PR merge edilmişse ne olur? Bir de şu: dışarıdan gelen bir katkıcının fork'tan açtığı PR'da plan job'u, Azure'a hangi kimlikle bağlanmalı?
Akış: PR'dan Apply'a
Kuracağımız akış aşağıdaki görselde. Üç workflow var: pull request'te kontrol ve plan, main'de onaylı apply, her sabah drift kontrolü.

Tahmin sorusunun ilk kısmının cevabı bu akışın temeli: apply, ekibin onayladığı plan'ı uygulamalı. 2. yazıda kaydedilmiş plan'ı (-out=tfplan) ve onun eskime davranışını görmüştük: plan alındıktan sonra state değişirse apply tfplan "Saved plan is stale" hatasıyla durur, başka bir şey yapmaz. Ama PR'daki plan, merge'den önceki koda ve o anki state'e göre alındı; aradaki her merge onu geçersiz kılar. Bu yüzden main'de plan yeniden alınıyor, kaydediliyor ve onaydan sonra tam olarak o dosya uygulanıyor. PR'daki plan review içindir, main'deki kaydedilmiş plan uygulama içindir.
Pull Request Workflow'u: Kontrol ve Plan
.github/workflows/terraform-pr.yml:
name: terraform-pr
on:
pull_request:
paths:
- "infra/**"
- "scripts/check-infra.sh"
- ".github/workflows/terraform-*.yml"
permissions:
contents: read
jobs:
checks:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: 1.17.2
terraform_wrapper: false
- uses: terraform-linters/setup-tflint@6ffdbaa3be476b3431f7275c26966ef32ff60337 # v6.3.2
with:
tflint_version: v0.66.0
- run: ./scripts/check-infra.sh
env:
# tflint --init azurerm ruleset'ini GitHub'dan indirir
GITHUB_TOKEN: ${{ github.token }}
plan:
needs: checks
# Fork'lardan gelen pull request'lere cloud credential'ı verilmez
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
environment: [dev, staging, prod]
# Salt okunur plan identity'sini tutar; federated credential yalnız bu environment'a güvenir
environment: ${{ matrix.environment }}-plan
permissions:
contents: read
id-token: write
pull-requests: write
env:
ARM_USE_OIDC: "true"
ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
TF_IN_AUTOMATION: "true"
defaults:
run:
working-directory: infra/azure/envs/${{ matrix.environment }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: 1.17.2
terraform_wrapper: false
- run: terraform init -input=false
# -lock=false: review için alınan plan, süren bir apply'ı bekletmemeli
- run: terraform plan -input=false -lock=false -no-color -out=tfplan
- run: terraform show -no-color tfplan > plan.txt
- name: Comment the plan summary
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
ENVIRONMENT: ${{ matrix.environment }}
with:
script: |
const summary = require("./scripts/plan-summary.js");
const body = summary(`infra/azure/envs/${process.env.ENVIRONMENT}/plan.txt`, process.env.ENVIRONMENT);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
İlk job, 10. yazıdaki scripts/check-infra.sh'i çalıştırıyor; cloud credential'ı istemiyor. Action'lar GitHub Actions serisinin güvenlik yazısındaki gibi commit SHA'sına (commit'in benzersiz hash'i) sabitli, yanındaki yorum okunabilir sürümü gösteriyor. terraform_wrapper: false önemli: setup-terraform varsayılan olarak terraform'u çıktısını yakalayan bir wrapper ile sarıyor; düz çıktıyla çalışan script'lerimizde buna gerek yok.
İkinci job her ortam için ayrı plan alıyor. Üç ayrıntı var. Birincisi if satırı: fork'tan gelen PR'larda plan job'u hiç çalışmıyor. Plan, kodu çalıştırmak demek; bir provider ya da module değiştirilerek credential'ı dışarı sızdıran kod yazılabilir. Fork PR'larında yalnız credential'sız checks çalışıyor; plan, kodu bir bakımcı kendi branch'ine aldığında alınıyor. İkincisi -lock=false: review için alınan plan state'e yazmaz ve süren bir apply'ı bekletmemeli. Üçüncüsü plan'ın tamamını değil, özetini PR'a yazmak:
// `terraform show -no-color tfplan` çıktısından kısa bir pull request yorumu üretir
const fs = require("fs");
module.exports = function summary(path, environment) {
const lines = fs.readFileSync(path, "utf8").split("\n");
const actions = lines.filter((line) => /^ # [^(]/.test(line));
const total = lines.find((line) => /^(Plan:|No changes\.)/.test(line)) ?? "No plan summary found.";
const destroys = actions.filter((line) => /will be destroyed|must be replaced/.test(line));
const warning = destroys.length > 0 ? `\n**${destroys.length} resource(s) will be destroyed or replaced.**\n` : "";
return [
`### Terraform plan: ${environment}`,
warning,
"```",
...actions,
total,
"```",
].join("\n");
};
if (require.main === module) {
console.log(module.exports(process.argv[2], process.argv[3] ?? "local"));
}
Script'i dev ortamında, image tag'ini değiştirdiğim bir plan'la lokal olarak denedim:
$ node scripts/plan-summary.js plan.txt dev
### Terraform plan: dev
**1 resource(s) will be destroyed or replaced.**
```
# module.bookstore.data.http.health will be read during apply
# module.bookstore.module.api.docker_container.this must be replaced
Plan: 1 to add, 0 to change, 1 to destroy.
```
PR yorumunda yalnız kaynak adresleri ve yapılacak işlem var; attribute değerleri yok. terraform show sensitive değerleri zaten gizliyor, ama plan'ın tamamı yine de iç IP aralıkları, resource ID'leri gibi PR'ı görebilen herkesin görmesi gerekmeyen ayrıntılar taşıyor. Silme ya da replace varsa yorumun başında kalın bir uyarı çıkıyor; 2. yazıdaki "-/+ işaretini gözden kaçırmak" tuzağına karşı.
OIDC ile Azure'a Secret'sız Giriş
Workflow'da hiçbir secret yok. ARM_CLIENT_ID, ARM_TENANT_ID ve ARM_SUBSCRIPTION_ID repository variable'larından geliyor; bunlar gizli değil. Kimlik doğrulamayı OIDC yapıyor: id-token: write izniyle GitHub, job için imzalı bir token üretiyor ve Azure'daki identity'nin federated credential'ı bu token'ı kabul ediyor. Azure tarafındaki kurulumu GitHub Actions serisinin OIDC ile Azure'a deploy yazısında anlattık; burada Terraform'a özgü iki nokta var.
Birincisi, ARM_USE_OIDC=true hem provider'ı hem backend'i etkiliyor. AzureRM backend'i aynı environment variable'larını okuyor; 4. yazıda versions.tf'e koyduğumuz use_azuread_auth = true ile birlikte state'e de storage key'i olmadan, aynı OIDC kimliğiyle erişiliyor. Ayrı bir azure/login adımına gerek yok.
İkincisi, plan ve apply için ayrı identity. Plan job'u dev-plan, staging-plan, prod-plan environment'larında çalışıyor, apply job'u dev, staging, prod'da. GitHub'ın OIDC token'ındaki subject, job bir environment'a bağlıysa environment adını içeriyor; Azure'daki her federated credential'ı tek bir environment'a bağladığında, prod-plan'ın token'ı prod'a apply edebilen identity'yi kullanamıyor. AZURE_CLIENT_ID variable'ı her environment'ta farklı bir değerle tanımlanıyor. Plan identity'si okuma rolleriyle yetinir: subscription'da Reader, state container'ında Storage Blob Data Reader ve Terraform'un Key Vault'taki secret'ı refresh edebilmesi için Key Vault Secrets User. Apply identity'si resource group'ları yazabilmeli ve bookstore-webapp bir role assignment oluşturduğu için rol atayabilmeli; bu en hassas yetki, mümkünse koşullu bir role assignment yetkisiyle sınırlandır.
Main Workflow'u: Kaydedilmiş Plan, Onay ve Apply
.github/workflows/terraform-apply.yml:
name: terraform-apply
on:
push:
branches: [main]
paths:
- "infra/azure/**"
- "infra/modules/**"
permissions:
contents: read
jobs:
plan:
runs-on: ubuntu-24.04
strategy:
matrix:
environment: [dev, staging, prod]
environment: ${{ matrix.environment }}-plan
permissions:
contents: read
id-token: write
env:
ARM_USE_OIDC: "true"
ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
TF_IN_AUTOMATION: "true"
defaults:
run:
working-directory: infra/azure/envs/${{ matrix.environment }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: 1.17.2
terraform_wrapper: false
- run: terraform init -input=false
- run: terraform plan -input=false -no-color -out=tfplan
# Kaydedilmiş plan secret'ları düz metin içerir: kısa saklama süresi, asla public değil
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: tfplan-${{ matrix.environment }}
path: infra/azure/envs/${{ matrix.environment }}/tfplan
retention-days: 1
apply:
needs: plan
runs-on: ubuntu-24.04
strategy:
max-parallel: 1
matrix:
environment: [dev, staging, prod]
# staging ve prod'daki protection rule'lar bu job başlamadan önce bir onaylayıcı ister
environment: ${{ matrix.environment }}
concurrency:
group: terraform-${{ matrix.environment }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
ARM_USE_OIDC: "true"
ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
TF_IN_AUTOMATION: "true"
defaults:
run:
working-directory: infra/azure/envs/${{ matrix.environment }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: 1.17.2
terraform_wrapper: false
- run: terraform init -input=false
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: tfplan-${{ matrix.environment }}
path: infra/azure/envs/${{ matrix.environment }}
# Tam olarak review edilen plan'ı uygular; eskimiş bir plan başka bir şey yapmak yerine hata verir
- run: terraform apply -input=false -no-color tfplan
Plan job'u her ortam için kaydedilmiş plan'ı bir artifact olarak yüklüyor. 9. yazıda gördüğümüz gibi bu dosya secret'ları düz metin taşıyor; saklama süresini bir güne indirdim. Apply job'u staging ve prod environment'larına bağlı ve bu environment'lardaki protection rule'lar, job başlamadan önce bir onaylayıcı istiyor (GitHub Actions serisinin environment'lar yazısı). Onaylayan kişi, aynı run'ın plan job'unun log'unda tam olarak ne uygulanacağını görüyor.
max-parallel: 1 ortamları sırayla uyguluyor: dev, staging, prod. concurrency ise aynı ortama iki apply'ın aynı anda çalışmasını engelliyor. 4. yazıdaki state lock bunu zaten durdurur, ama lock hatasıyla kırmızıya dönen bir job yerine sırada bekleyen bir job daha iyi. cancel-in-progress: false bilinçli: yarıda kesilen bir apply, kısmen uygulanmış bir altyapı bırakır.
Tahmin sorusunun ikinci kısmı: plan ile apply arasında başka bir merge olursa ne olur? İkinci merge kendi workflow run'ını başlatır ve onun apply'ı concurrency yüzünden ilkini bekler. İlk run'ın apply'ı uygulandığında state değişir; ikinci run'ın, ilkinden önce alınmış kaydedilmiş plan'ı artık eski. apply tfplan stale plan hatasıyla durur ve ikinci değişiklik yeniden planlanmadan uygulanmaz. Yanlış bir şey yapmaktansa durmak istediğimiz davranış bu. Bir ayrıntı: bir concurrency grubunda aynı anda en fazla bir çalışan ve bir bekleyen job olabiliyor; üçüncü bir run gelirse bekleyen run iptal ediliyor. Arka arkaya merge'lerde aradaki apply'ın atlanması bu yüzden normal; son run'ın kaydedilmiş plan'ı da eskidiyse stale hatası verir ve run yeniden çalıştırılır.
Workflow'ları actionlint ile kontrol ettim:
$ actionlint .github/workflows/*.yml
$ echo $?
0
actionlint, matrix context'inin environment ve concurrency'de kullanılabilirliğini, action input'larını ve permissions anahtarlarını kontrol ediyor. Workflow'ları act ile de çalıştırmadım: job'ların asıl işi OIDC token'ı alıp Azure'a bağlanmak, act bunu taklit edemiyor. Credential gerektirmeyen kısmı, yani check-infra.sh'i ve aşağıdaki drift script'ini lokal olarak çalıştırdım.
Zamanlanmış Drift Kontrolü
5. yazıda check block'larının uyarılarının ancak biri plan aldığında görüneceğini, 8. yazıda da elle yapılan değişikliklerin ancak plan'da fark edileceğini söylemiştik. Kimse PR açmasa da her sabah bir plan alan bir workflow bu boşluğu kapatıyor. terraform-drift.yml her ortam için şu script'i çalıştırıyor:
#!/usr/bin/env bash
# Plan bir kaynağı değiştirecekse hata verir: drift ya da hiç apply edilmemiş kod
set -euo pipefail
terraform plan -input=false -lock=false -no-color -out=drift.tfplan > drift.txt
cat drift.txt
# Apply sırasında okunan data source'lar (check block'umuz) değişiklik sayılmaz
changes=$(terraform show -json drift.tfplan |
jq '[.resource_changes[]? | select(.change.actions != ["no-op"] and .change.actions != ["read"])] | length')
if grep -q "Check block assertion failed" drift.txt; then
echo "::warning::A check block failed; see the plan output."
fi
if [ "$changes" -gt 0 ]; then
echo "::error::Drift detected: $changes resource(s) would change."
exit 1
fi
echo "No drift."
Script'in bu hâle gelmesi üç deneme sürdü. İlk akla gelen plan -refresh-only -detailed-exitcode idi: yalnız dışarıdan yapılan değişiklikleri gösterir ve fark varsa 2 ile çıkar. Docker provider'ında, hiçbir şey değişmemişken bile şunu verdi:
$ terraform plan -refresh-only -detailed-exitcode
...
Note: Objects have changed outside of Terraform
...
# module.bookstore.module.api.docker_container.this has changed
...
+ dns = []
...
- networks_advanced {
...
+ networks_advanced {
...
$ echo $?
2
Provider, container'ı okurken boş listeleri farklı biçimde döndürüyor; refresh-only bu farkları "dışarıda değişti" diye raporluyor. Her sabah kırmızıya dönen bir job, kimsenin bakmadığı bir job olur. İkinci deneme normal bir plan'la -detailed-exitcode:
$ terraform plan -detailed-exitcode
...
# module.bookstore.data.http.health will be read during apply
# (config will be reloaded to verify a check block)
...
Plan: 0 to add, 0 to change, 0 to destroy.
$ echo $?
2
Plan "0 to add, 0 to change, 0 to destroy" diyor ama çıkış kodu yine 2, çünkü check block'unun data source'u "apply sırasında okunacak" ve Terraform bunu da bir değişiklik sayıyor. Son hâl, plan'ı kaydedip terraform show -json ile yalnız kaynak değişikliklerini saymak; data source okumalarını ve no-op'ları dışarıda bırakıyor. Yerel dev ortamında container'ı elle durdurarak denedim:
$ cd infra/local/envs/dev
$ ../../../../scripts/drift-check.sh
...
Plan: 0 to add, 0 to change, 0 to destroy.
...
No drift.
$ docker stop tfseries-dev-api
$ ../../../../scripts/drift-check.sh
...
# module.bookstore.module.api.docker_container.this must be replaced
...
~ must_run = false -> true
...
Plan: 1 to add, 0 to change, 1 to destroy.
...
::error::Drift detected: 1 resource(s) would change.
Drift varsa job başarısız oluyor; bu, ekibin bildirim kanalına bağlanması gereken sinyal. Drift job'u yalnız plan identity'siyle çalışıyor ve hiçbir şeyi düzeltmiyor; düzeltme, PR'la ya da drift'in neden oluştuğu anlaşıldıktan sonra bilinçli bir apply ile yapılır.
HCP Terraform: Alternatif Yol
Buraya kadar kurduğumuz her şeyin (remote state, lock, PR'da plan, onaylı apply, drift, policy) hazır bir karşılığı HCP (HashiCorp Cloud Platform; HashiCorp'un yönetilen bulut platformu) Terraform'da var. Plan ve apply HashiCorp'un runner'larında çalışıyor, VCS (Git gibi sürüm kontrol sistemi) entegrasyonu plan sonucunu PR'a kendisi yazıyor, state ve değişkenler workspace'te tutuluyor. HashiCorp'un duyurusuna göre eski ücretsiz plan 31 Mart 2026'da sona erdi; yerine gelen Free katmanı sınırsız kullanıcı ve 500 managed resource'a kadar ücretsiz, üstü yönetilen kaynak başına ücretlendiriliyor. Bookstore'un bugünkü kaynak sayısı bu sınırın çok altında. Seçim, kendi pipeline'ını ve backend'ini yönetmek ile bir SaaS'a (yazılımın hizmet olarak sunulması) bağımlı olmak arasında; 1. yazıdaki ADR (Architecture Decision Record; mimari kararın gerekçesiyle yazıldığı kısa doküman) tam bu tür bir kararı yazmak için.
Üretimde Dikkat
Plan artifact'ı state kadar hassas. Repository'ye okuma yetkisi olan herkes artifact'ları indirebilir; saklama süresini kısa tut, public repo'da kaydedilmiş plan'ı artifact olarak hiç yükleme. Alternatif, apply job'unda plan'ı yeniden almak ve plan'ın özetini onaylanan özetiyle karşılaştırmak; daha karmaşık ama plan dosyasını taşımaz.
Ortam sayısı ve klasör sayısı arttıkça her PR'da bütün ortamların plan'ını almak yavaşlar. Değişen klasörlere göre matrix'i daraltmak (GitHub Actions serisindeki path filtreleri) ve TF_PLUGIN_CACHE_DIR'i actions/cache ile saklamak süreyi düşürür. Module değiştiğinde ise onu kullanan bütün ortamların plan'ı alınmalı.
Drift job'unu sessiz bırakma. Düzenli olarak kırmızıya dönüp kimsenin bakmadığı bir job hiç olmamasından kötü, çünkü gerçek bir drift'i de aynı gürültünün içinde saklar. Yukarıdaki gibi yanlış alarmları ayıkla, kalan her alarmın bir sahibi olsun.
Sık Düşülen Tuzaklar
Fork PR'larında plan'a cloud credential'ı vermek. pull_request_target ile fork'un kodunu credential'lı bir job'da çalıştırmak, GitHub Actions serisinin güvenlik yazısında anlattığımız saldırının Terraform sürümü: kötü niyetli bir provider ya da external data source plan sırasında çalışır ve token'ı dışarı gönderebilir. Plan da kod çalıştırır.
Plan ile apply arasında kodun değişmesi. Apply job'unda terraform apply -auto-approve ile yeni bir plan almak, onaylanan plan ile uygulanan plan'ın farklı olabileceği demek. Kaydedilmiş plan'ı uygula ya da en azından yeniden alınan plan'ı onaylananla karşılaştır.
Plan ve apply için tek identity. PR'da çalışan plan job'u, yazma yetkili bir identity kullanıyorsa, PR'daki kodla o yetki kullanılabilir. Plan'ın identity'si okur, apply'ınki onaydan sonra yazar.
Küçük Bir Deneme
★☆☆ plan-summary.js'i, yerel staging ortamında container'ı olmayan bir plan'la çalıştır. Yorumda kaç satır var, uyarı çıkıyor mu?
★★☆ terraform-pr.yml'de matrix'i, yalnız değişen ortam klasörlerini içerecek şekilde dinamik hâle getir (değişen dosyaları bulan bir job ve fromJSON ile). infra/azure/bookstore değiştiğinde hangi ortamlar planlanmalı?
★★★ Drift script'ini, drift bulduğunda job'u kırmak yerine bir GitHub issue'su açacak ya da var olan issue'yu güncelleyecek şekilde değiştir. Aynı drift için her sabah yeni bir issue açılmasını nasıl engellersin?
Serinin son yazısında geriye bakıyoruz: Terraform 1.5'ten 1.17'ye gelen özellikler, AzureRM 3'ten 5'e değişenler, eski Terraform kodunu okumak ve OpenTofu'nun farkları.