Terraform State: Ne İşe Yarar, Azure Storage'da Remote Backend, Locking ve State Komutları
State, koddaki her kaynağı gerçek dünyadaki nesnesiyle eşleyen ve içinde secret'ları düz metin tutan dosyadır; bu yüzden laptop'ta ya da Git'te değil, erişimi kısıtlı ve locking yapan bir remote backend'de durur. Backend'i bootstrap stack'iyle kuruyor, state'i -migrate-state ile taşıyor, gerçek bir lock hatasını okuyor, Azure Storage backend'ini Entra ID ile yazıyor ve state mv, rm, import ile -refresh-only'yi deniyoruz.
Sürüm notu: Bu yazıdaki örnekler Terraform 1.17.2, kreuzwerker/docker provider 4.7.0, hashicorp/random 3.9.1, PostgreSQL 18 (pg backend) ve Docker Engine 29 ile denenmiştir. Azure Storage backend'i ve AzureRM 5.14.0 kodu için init ve validate çalıştırıldı; Azure'a bağlanılmadı.
Önce Tahmin Et
Ayşe ve Emre aynı repo'yu klonlamış, ikisi de Bookstore'un dev ortamına kendi laptop'undan apply ediyor. State her ikisinde de lokal terraform.tfstate dosyasında. Ayşe bir volume ekleyip apply ediyor. Ertesi gün Emre başka bir değişiklik için plan alıyor. Emre'nin plan'ı Ayşe'nin eklediği volume'ü görür mü? Görmüyorsa ne olur? İkinci soru: ikisi aynı anda apply etseydi ne olurdu? Yazının sonunda iki sorunun da cevabını gerçek bir lock hatasıyla göreceğiz.
State Ne İşe Yarar?
1. yazıda state'i "Terraform'un hafızası" diye tanımlamıştık. Biraz daha açalım. State'in üç işi var. Birincisi eşleme: koddaki docker_network.bookstore adresinin gerçek dünyadaki hangi nesneye karşılık geldiğini, yani ID'sini tutar. Docker'da bu bir network ID'si, Azure'da /subscriptions/.../resourceGroups/rg-bookstore-dev-weu gibi bir resource ID. İkincisi metadata: kaynaklar arasındaki bağımlılıkları ve provider bilgisini saklar, böylece koddan silinmiş bir kaynağın hangi sırayla ve hangi provider'la silineceğini bilir. Üçüncüsü performans: büyük bir altyapıda her plan'da her şeyi sıfırdan keşfetmek yerine state'teki listeden başlar.
3. yazının sonundaki yerel örneğin state dosyası, kısaltılmış hâliyle:
{
"version": 4,
"terraform_version": "1.17.2",
"serial": 24,
"lineage": "38798711-cf2d-6c7a-223f-6c1b96392845",
"outputs": { ... },
"resources": [
...
{
"mode": "managed",
"type": "docker_network",
"name": "bookstore",
"provider": "provider[\"registry.terraform.io/kreuzwerker/docker\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"driver": "bridge",
"id": "d944056ddbb65636184fe660d8266875395e5662b03e6646ee07ff50c50e21ee",
"name": "tfseries-dev",
...
},
"sensitive_attributes": [],
...
}
]
}
]
}
serial her yazmada bir artar, lineage ise bu state'in kimliği; ikisi birlikte Terraform'un eski ya da başka bir state'i yanlışlıkla üzerine yazmasını engeller. Container'ın kaydında sensitive_attributes env'i gösteriyor, ama 3. yazıda gördüğümüz gibi değerin kendisi aynı dosyada düz metin. State'i elle açıp okumak yerine komutları kullanırsın:
$ terraform state list
docker_container.api
docker_network.bookstore
docker_volume.data["covers"]
docker_volume.data["exports"]
$ terraform state show docker_network.bookstore
# docker_network.bookstore:
resource "docker_network" "bookstore" {
attachable = false
driver = "bridge"
id = "d944056ddbb65636184fe660d8266875395e5662b03e6646ee07ff50c50e21ee"
...
name = "tfseries-dev"
...
}
State Neden Laptop'ta Durmaz?
Tahmin sorusuna dönelim. Ayşe'nin apply'ı yalnız onun laptop'undaki state'i güncelledi. Emre'nin state'i volume'ün varlığından habersiz. Emre plan aldığında Terraform kendi state'indeki kaynakları refresh eder; Ayşe'nin volume'ü orada olmadığı için hiç sorulmaz. Kod Emre'de de volume'ü tarif ettiği için plan onu oluşturmak ister. Docker volume'lerinde create aynı adlı volume'ü kabul ettiği için bu sessizce geçebilir; Azure'da ise "already exists" hatası alırsın ya da daha kötüsü, iki kişinin state'i farklı gerçeklikleri anlatmaya başlar.
Akla gelen ilk çözüm state'i Git'e commit'lemek; yanlış bir çözüm. State içinde secret'lar düz metin durur ve repo'ya erişen herkes onları okur. Ayrıca Git eşzamanlı yazmayı engellemez: iki kişi aynı anda apply eder, ikisi de commit'ler, merge conflict'i JSON içinde elle çözmek zorunda kalırsın. Çözüm, state'i herkesin eriştiği tek bir yerde tutan ve aynı anda tek bir yazmaya izin veren bir remote backend.

Önce Backend'i Kurmak: Tavuk-Yumurta Problemi
Bookstore'un state'ini Azure'da bir Storage Account'ta tutacağız. Ama o Storage Account'u da Terraform ile kurmak istiyoruz ve onun state'i nerede duracak? Bu tavuk-yumurta problemine verilen standart cevap, backend'i kuran küçük bir bootstrap stack'i. Bu stack kendi state'ini lokal tutar (ya da platform ekibi onu bir kez, elle çalıştırır); bir kez kurulduktan sonra nadiren değişir. Azure için:
# Bookstore'un Terraform state'ini tutan storage account'u oluşturur.
# Bu stack kendi state'ini lokal tutar (ya da platform ekibi bir kez çalıştırır).
terraform {
required_version = "~> 1.17"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.14"
}
}
}
provider "azurerm" {
resource_providers_to_register = ["Microsoft.Storage"]
features {}
}
variable "state_writer_principal_ids" {
type = set(string)
description = "Object IDs (people, CI identities) allowed to read and write state."
default = []
}
resource "azurerm_resource_group" "tfstate" {
name = "rg-bookstore-tfstate-weu"
location = "westeurope"
tags = {
app = "bookstore"
purpose = "terraform-state"
managed_by = "terraform"
}
}
resource "azurerm_storage_account" "tfstate" {
name = "stbookstoretfstateweu"
resource_group_name = azurerm_resource_group.tfstate.name
location = azurerm_resource_group.tfstate.location
account_tier = "Standard"
account_replication_type = "ZRS"
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
default_to_oauth_authentication = true
tags = azurerm_resource_group.tfstate.tags
blob_properties {
versioning_enabled = true
delete_retention_policy {
days = 30
}
container_delete_retention_policy {
days = 30
}
}
}
resource "azurerm_storage_container" "tfstate" {
name = "tfstate"
storage_account_id = azurerm_storage_account.tfstate.id
container_access_type = "private"
}
resource "azurerm_role_assignment" "state_writers" {
for_each = var.state_writer_principal_ids
scope = azurerm_storage_account.tfstate.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = each.key
}
Birkaç bilinçli tercih var. versioning_enabled her state yazmasında eski sürümü saklar; bozuk bir apply'dan sonra state'i bir önceki sürüme döndürmenin yolu bu. delete_retention_policy silinen blob'u otuz gün geri alınabilir tutar. shared_access_key_enabled = false storage account key'iyle erişimi kapatır, böylece state'e yalnız Entra ID kimliğiyle ve rol atamasıyla erişilir. ZRS (zone'lar arası yedekli storage) de state'i aynı region'daki üç availability zone'a kopyalar. Bu stack'i Azure'a uygulamadım; validate çalıştı.
Yerel eşte aynı fikri Docker ile kuruyoruz: bir PostgreSQL container'ı, Terraform'un pg backend'i için. Şifreyi random_password üretiyor, bağlantı bilgisi sensitive bir output:
# Bu stack kendi state'ini lokal tutar: diğer stack'lerin kullandığı backend'i o kurar.
terraform {
required_version = "~> 1.17"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.7"
}
random = {
source = "hashicorp/random"
version = "~> 3.9"
}
}
}
provider "docker" {}
resource "random_password" "state_db" {
length = 24
special = false
}
resource "docker_image" "postgres" {
name = "postgres:18-alpine"
keep_locally = true
}
resource "docker_volume" "state_db" {
name = "tfseries-state-data"
}
resource "docker_container" "state_db" {
name = "tfseries-state"
image = docker_image.postgres.image_id
restart = "unless-stopped"
wait = true
env = [
"POSTGRES_USER=terraform",
"POSTGRES_PASSWORD=${random_password.state_db.result}",
"POSTGRES_DB=terraform_state",
]
healthcheck {
test = ["CMD", "pg_isready", "-U", "terraform", "-d", "terraform_state"]
interval = "2s"
retries = 15
}
volumes {
volume_name = docker_volume.state_db.name
container_path = "/var/lib/postgresql"
}
ports {
internal = 5432
external = 15480
}
}
output "pg_conn_str" {
description = "Connection string for the pg backend (PG_CONN_STR)."
value = "postgres://terraform:${random_password.state_db.result}@localhost:15480/terraform_state?sslmode=disable"
sensitive = true
}
random_password.state_db: Creation complete after 0s [id=none]
docker_image.postgres: Creation complete after 1s [id=sha256:1018733457011bf88d81db98cf43aac6d5150afc18787dbe204771fedf80ff95postgres:18-alpine]
docker_container.state_db: Creating...
docker_container.state_db: Creation complete after 3s [id=3e9e812ea7764599bc16573eece3b48e5c29b05b383fa46b0bcf8cb067724e1c]
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
Outputs:
pg_conn_str = <sensitive>
wait = true ve healthcheck sayesinde Terraform, container PostgreSQL bağlantı kabul edene kadar bekledi. Bootstrap stack'in kendi state'inde veritabanı şifresi düz metin duruyor; gerçek hayatta bu stack'i platform ekibi korumalı bir yerde çalıştırır.
State'i Remote Backend'e Taşımak
Yerel örneğin versions.tf'ine bir backend block'u ekliyoruz:
terraform {
required_version = "~> 1.17"
# Bağlantı bilgisi PG_CONN_STR environment variable'ından gelir
backend "pg" {
schema_name = "bookstore_dev"
}
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.7"
}
}
}
Backend block'unda variable kullanılamaz, çünkü backend variable'lardan önce, init sırasında kurulur. Bu yüzden ya değerler doğrudan yazılır ya da ortam ve gizli değerler dışarıdan verilir; burada bağlantı bilgisi PG_CONN_STR'den geliyor. Backend değişince init'i -migrate-state ile çalıştırıyoruz:
$ export PG_CONN_STR=$(terraform -chdir=../bootstrap-local output -raw pg_conn_str)
$ terraform init -migrate-state
Initializing the backend...
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend to the
newly configured "pg" backend. No existing state was found in the newly
configured "pg" backend. Do you want to copy this state to the new "pg"
backend? Enter "yes" to copy and "no" to start with an empty state.
Enter a value: yes
Successfully configured the backend "pg"! Terraform will automatically
use this backend unless the backend configuration changes.
PowerShell'de ilk satırın karşılığı $env:PG_CONN_STR = terraform -chdir=../bootstrap-local output -raw pg_conn_str. Taşımadan sonraki plan "No changes." diyor, state de artık veritabanında:
$ docker exec tfseries-state psql -U terraform -d terraform_state -c "select id, name, length(data) as bytes from bookstore_dev.states;"
id | name | bytes
----+---------+-------
1 | default | 10219
(1 row)
Klasörde ise bir sürpriz kalıyor:
$ wc -c terraform.tfstate terraform.tfstate.backup
0 terraform.tfstate
10220 terraform.tfstate.backup
10220 total
Terraform lokal state'i boşalttı, ama taşımadan önceki tam hâli terraform.tfstate.backup'ta, içindeki secret'larla birlikte duruyor. Taşımanın başarılı olduğunu doğruladıktan sonra bu dosyayı sil; .gitignore'daki *.tfstate.* satırı onu commit'ten korur ama diskten silmez.
Locking: Aynı Anda Tek Yazar
Remote backend'in ikinci işi, aynı anda iki işlemin state'e yazmasını engellemek. Ayşe'nin bir apply'ı onay ekranında beklerken Emre plan alıyor:
Error: Error acquiring the state lock
Error message: Workspace is already locked: default
Lock Info:
ID: d04ef8d6-0b12-5d3c-e7e4-77561e7b4805
Path:
Operation: OperationTypePlan
Who: user@my-pc
Version: 1.17.2
Created: 2026-11-14 16:59:16.073638 +0000 UTC
Info:
Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try
again. For most commands, you can disable locking with the "-lock=false"
flag, but this is not recommended.
İkinci sorunun cevabı bu: ikinci işlem başlamadan durur. Who satırındaki kullanıcı ve makine adını gizledim. Burada pg backend'e özgü bir ayrıntı var: PostgreSQL'in advisory lock'larında kilidi kimin tuttuğuna dair bilgi saklanmıyor, bu yüzden Lock Info'daki zaman ve operasyon Emre'nin kendi denemesine ait. Bunu, aynı komutu birkaç saniye arayla iki kez çalıştırıp Created değerinin her seferinde değiştiğini görerek doğruladım. Azure Storage backend'i ise kilidi alırken lock bilgisini blob'un metadata'sına yazar; orada gerçekten kilidi tutanı görürsün.
Hata yerine beklemek istiyorsan -lock-timeout=5m ver; CI job'larında kullanışlıdır. Kilidi tutan süreç çökerse ne olur? Ayşe'nin bekleyen apply'ını kill -9 ile öldürdüm; Emre'nin bir sonraki plan'ı hiç beklemeden "No changes." dedi. pg backend'in dokümanı bunu açıklıyor: advisory lock oturuma bağlı, bağlantı kopunca kendiliğinden açılır, bu yüzden force-unlock desteklenmiyor. Azure'daki blob lease ise süresiz alınır ve çöken süreç onu bırakmaz. O durumda terraform force-unlock <ID> gerekir. Tehlike şurada: kilidi tutan süreç gerçekten ölmüş mü, yoksa bir CI runner'ında hâlâ apply mı ediyor? İkincisiyse force-unlock iki yazarı aynı anda serbest bırakır ve state bozulabilir. Force-unlock'tan önce kilidin sahibini bul ve sürecin bittiğinden emin ol.
AWS (Amazon'un sunduğu cloud platformu) kullanan ekipler için kısa bir not: S3 (AWS'nin object storage service'i) backend'i Terraform 1.10'dan beri use_lockfile = true ile kilidi bucket'taki bir .tflock dosyasıyla tutabiliyor. Eskiden bunun için ayrı bir DynamoDB tablosu gerekiyordu; dynamodb_table argument'ı artık dokümanda deprecated olarak işaretli.
Azure Storage Backend'i
Azure tarafında backend block'u partial configuration ile yazılır: koda yalnız her ortamda aynı olan ayar girer, gerisi ortam başına bir dosyadan gelir.
terraform {
required_version = "~> 1.17"
# Partial configuration: geri kalanı -backend-config=backend/<environment>.azurerm.tfbackend ile gelir
backend "azurerm" {
use_azuread_auth = true
}
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.14"
}
}
}
resource_group_name = "rg-bookstore-tfstate-weu"
storage_account_name = "stbookstoretfstateweu"
container_name = "tfstate"
key = "bookstore/dev.tfstate"
terraform init -backend-config=backend/dev.azurerm.tfbackend
use_azuread_auth = true backend'in storage account key'i yerine Entra ID kimliğiyle bağlanmasını sağlar; yukarıdaki bootstrap'te key erişimini zaten kapattık. Backend dokümanı bu yöntem için en az yetki olarak state container'ında Storage Blob Data Contributor rolünü öneriyor. Laptop'ta kimlik Azure CLI oturumundan gelir; CI'da OIDC (OpenID Connect; pipeline'ın secret saklamadan kısa ömürlü token'la kimlik doğrulaması) ile use_oidc = true ya da ARM_USE_OIDC kullanılır, ayrıntısı 11. yazıda. Kilitleme için ayrıca bir ayar gerekmiyor; backend Azure Blob Storage'ın kendi lease mekanizmasını kullanıyor.
Bu backend'e bağlanmadım. Credential olmadan init şu hatayla duruyor:
Error: unable to build authorizer for Storage API: could not configure AzureCli Authorizer: could not parse Azure CLI version: launching Azure CLI: exec: "az": executable file not found in $PATH
Backend'e bağlanmadan kodu doğrulamak için terraform init -backend=false ve ardından terraform validate yeterli. Terraform 1.15'ten beri validate backend block'unu da kontrol ediyor: backend tipinin var olduğunu ve zorunlu ayarların yazıldığını.
State Komutları: mv, rm, import ve -refresh-only
State'i elle düzenlemek yerine onun için yazılmış komutlar var. İlki state mv: bir kaynağın adresini değiştirir. Volume'lerin aslında API'ye ait olduğunu adında da göstermek için docker_volume.data'yı docker_volume.api_data yapıyoruz. Yalnız kodda adı değiştirseydik plan şunu derdi:
# docker_volume.api_data["covers"] will be destroyed
# (because docker_volume.api_data is not in configuration)
# docker_volume.api_data["exports"] will be destroyed
# (because docker_volume.api_data is not in configuration)
# docker_volume.renamed["covers"] will be created
# docker_volume.renamed["exports"] will be created
Plan: 2 to add, 0 to change, 2 to destroy.
Bu çıktı, adı denemek için bir kez daha renamed'e çevirdiğim koddan; mesele aynı: Terraform yeni adı yeni bir kaynak, eski adı silinmiş bir kaynak sanar. Doğrusu state'teki adresi de taşımak:
$ terraform state mv docker_volume.data docker_volume.api_data
Move "docker_volume.data" to "docker_volume.api_data"
Successfully moved 1 object(s).
Kodda main.tf ve outputs.tf'teki referansları da güncelleyince plan "No changes." diyor. state mv hemen uygulanır ve review'dan geçmez; aynı işi kodda, pull request'te görünen bir moved block'uyla yapmak daha iyi bir yol, 8. yazıda.
state rm bir kaynağı Terraform'un yönetiminden çıkarır ama gerçek dünyada silmez. Kodda tanımı duruyorsa sonucu şaşırtıcı olabilir:
$ terraform state rm docker_network.bookstore
Removed docker_network.bookstore
Successfully removed 1 resource instance(s).
$ terraform plan -var-file=dev.tfvars
...
# docker_network.bookstore will be created
Plan: 1 to add, 0 to change, 0 to destroy.
Network hâlâ Docker'da duruyor, ama Terraform onu artık tanımadığı için yeniden oluşturmak istiyor; apply etseydin 1. yazıdaki "already exists" hatasını alırdın. Geri almak için kaynağı ID'siyle tekrar import ediyoruz:
$ terraform import -var-file=dev.tfvars docker_network.bookstore d944056ddbb65636184fe660d8266875395e5662b03e6646ee07ff50c50e21ee
docker_network.bookstore: Importing from ID "d944056ddbb65636184fe660d8266875395e5662b03e6646ee07ff50c50e21ee"...
docker_network.bookstore: Import prepared!
Prepared docker_network for import
docker_network.bookstore: Refreshing state... [id=d944056ddbb65636184fe660d8266875395e5662b03e6646ee07ff50c50e21ee]
Import successful!
Son komut -refresh-only. Biri container'ın restart policy'sini Docker'dan elle değiştiriyor (docker update --restart=no tfseries-dev-api). terraform plan -refresh-only yalnız gerçek dünyayı okur ve state ile farkı gösterir, kodla karşılaştırmaz:
Note: Objects have changed outside of Terraform
Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:
# docker_container.api has changed
~ resource "docker_container" "api" {
id = "79417355f8fa56076db1106f8a64f0201e6f206dbab001b86ca26e900108d4e8"
name = "tfseries-dev-api"
~ restart = "unless-stopped" -> "no"
...
}
...
This is a refresh-only plan, so Terraform will not take any actions to undo
these. If you were expecting these changes then you can apply this plan to
record the updated values in the Terraform state without changing any remote
objects.
Gerçek çıktı daha uzun: provider boş listeleri ve block'ları ilk refresh'te normalize ettiği için network ve volume'lerde de "has changed" satırları vardı; onları kısalttım. Drift'i gördükten sonra iki seçeneğin var. Değişiklik doğruysa önce kodu ona göre güncellersin, sonra terraform apply -refresh-only ile yeni değeri state'e kaydedersin. Değişiklik yanlışsa normal terraform apply kodu esas alır ve restart policy'yi unless-stopped'a geri çeker. Ben ikincisini yaptım; ardından alınan -refresh-only plan'ı "No changes. Your infrastructure still matches the configuration." dedi.
Üretimde Dikkat
State'e erişimi uygulamanın production veritabanına erişim gibi yönet. State'i okuyabilen biri içindeki connection string'leri ve şifreleri okuyabilir. Azure'da bu, state container'ında rol atamasını yalnız Terraform'u çalıştıran identity'lere ve birkaç kişiye vermek demek.
Her ortamın state'i ayrı bir key'de, mümkünse ayrı bir storage account'ta dursun. Tek bir state'te dev ile prod birlikte duruyorsa, dev'deki bir hata prod'u da kilitler ya da bozar. Ortamları bölmenin yollarını 7. yazıda konuşacağız.
Versioning ve soft delete'i açık tut ve bir state'i eski sürümüne döndürmeyi en az bir kez dene. Bunu ilk kez gerçek bir arızada öğrenmek istemezsin.
Sık Düşülen Tuzaklar
State'i Git'e commit'lemek. Secret'lar repo geçmişine girer, geçmişten silmek de zahmetlidir. .gitignore'daki *.tfstate satırı ilk günden orada olmalı.
İki kişinin lokal state ile aynı ortama apply etmesi. Kimse hata almaz, ama iki state iki farklı gerçeklik anlatmaya başlar. Ekipte ikinci kişi projeye katıldığı gün remote backend'e geç.
State'i elle düzenlemek ya da lock hatasını -lock=false ile geçmek. JSON'u elle değiştirmek serial'ı ve bağımlılık bilgisini bozabilir; -lock=false ise locking'in koruduğu tek şeyi, eşzamanlı yazmayı serbest bırakır. Elle düzeltme gerekiyorsa state mv, state rm, import ya da terraform state pull ve push kullan; her birinden önce de state'in bir yedeğini al.
Küçük Bir Deneme
★☆☆ Bootstrap stack'i kur, yerel örneği -migrate-state ile pg backend'e taşı ve terraform state pull çıktısındaki serial değerini not et. Bir apply daha yap ve değerin nasıl değiştiğine bak.
★★☆ İki terminal aç. Birinde terraform apply -var-file=dev.tfvars -replace=docker_container.api çalıştırıp onay ekranında bekle, diğerinde önce terraform plan, sonra terraform plan -lock-timeout=60s çalıştır. İlk terminalde "no" yazınca ikincisinde ne oluyor?
★★★ Bootstrap stack'teki PostgreSQL şifresi bootstrap'in lokal state'inde düz metin duruyor. Bu stack'i bir ekipte nasıl çalıştırırdın? En az iki seçenek düşün ve her birinin tradeoff'unu yaz.
Bir sonraki yazıda Terraform'un kaynakları hangi sırayla oluşturduğunu belirleyen dependency graph'a bakıyoruz: implicit dependency ile depends_on, var olan kaynakları okuyan data source'lar, lifecycle ayarları (create_before_destroy, prevent_destroy, ignore_changes) ve check block'larıyla API'nin health endpoint'ini Terraform'dan kontrol etmek.