Mert Özen Her Satırda Daha İleri
DevOps Infrastructure as Code: Terraform — #03

HCL'i Tanımak: Variable, Output, Local, Expression'lar, count, for_each ve Dynamic Block

Mert Özen 14 Kas 2026 14 dk 9 görüntülenme
HCL'i Tanımak: Variable, Output, Local, Expression'lar, count, for_each ve Dynamic Block

HCL'de variable dışarıdan gelen değer, local kod içinde hesaplanan ara değer, output da dışarıya verilen sonuçtur; count kaynakları sıra numarasıyla, for_each key'iyle tanıdığı için listenin ortasından eleman silmek count'ta komşu kaynakları yeniden yaratır. Tipli ve validation'lı variable'lar, tfvars ve TF_VAR_, merge/lookup/try/convert, for expression'ları ve dynamic block'larla Bookstore'un App Service, Azure SQL ve Storage kodunu yazıyoruz.

Sürüm notu: Bu yazıdaki örnekler Terraform 1.17.2, AzureRM provider 5.14.0, kreuzwerker/docker provider 4.7.0 ve Docker Engine 29 ile denenmiştir. Azure kodu için init, validate ve fmt çalıştırıldı; Azure'a plan/apply yapılmadı.

Önce Tahmin Et

Bookstore API'si için üç Docker volume'ü count ile açıyorsun: ["covers", "exports", "invoices"]. Bir süre sonra exports'a gerek kalmıyor ve listeden siliyorsun. Plan kaç kaynağa dokunur? Yalnız exports mu silinir, yoksa faturaların durduğu invoices volume'ü de etkilenir mi? Cevap yazının ortasında, gerçek plan çıktısıyla.

Variable'lar: Tipler, validation, sensitive ve nullable

Şu ana kadar her değer kodun içine yazılıydı: network adı, port, image tag'i. Aynı kodu dev, staging ve production için kullanmak istediğimizde bu değerlerin dışarıdan gelmesi gerekiyor. Terraform'da bunun yolu input variable'lar. Yerel örneğin variables.tf dosyası:

variable "environment" {
  type        = string
  description = "Deployment environment: dev, staging or prod."

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "The environment must be one of: dev, staging, prod."
  }
}

variable "api" {
  type = object({
    image_tag     = string
    external_port = number
    app_settings  = optional(map(string), {})
  })
  description = "Bookstore API container settings."

  validation {
    condition     = var.api.external_port >= 1024 && var.api.external_port <= 65535
    error_message = "The external_port must be between 1024 and 65535."
  }
}

variable "volume_names" {
  type        = set(string)
  description = "Data volumes mounted under /data in the API container."
  default     = ["covers", "exports"]
  nullable    = false
}

variable "payments_api_key" {
  type        = string
  description = "Key for the payment provider's sandbox."
  sensitive   = true
}

Her variable'ın bir type'ı var. string, number ve bool basit tipler; list, set ve map aynı tipten değerlerin koleksiyonları; object ise her alanın kendi tipi olan bir yapı. api variable'ı bir object ve app_settings alanı optional(map(string), {}) olarak tanımlı: verilmezse boş map olur. Bu, C#'taki bir record'un varsayılan değerli property'sine benziyor.

validation block'ları değeri plan'dan önce kontrol eder. condition true dönmezse Terraform error_message'ı gösterip durur. Ortam adını test olarak verince:

Error: Invalid value for variable

  on variables.tf line 1:
   1: variable "environment" {
    ├────────────────
    │ var.environment is "test"

The environment must be one of: dev, staging, prod.

This was checked by the validation rule at variables.tf:5,3-13.

Port için de aynısı: external_port = 80 verince "The external_port must be between 1024 and 65535." hatası geliyor. Validation, yanlış bir değerin Azure'da yarım kalmış bir apply'a dönüşmesini en ucuz noktada, laptop'ta yakalar.

sensitive = true değeri plan ve apply çıktısında gizler. nullable = false ise biri değeri bilerek null verirse Terraform'un varsayılanı kullanmasını sağlar; -var 'volume_names=null' ile denediğimde var.volume_names yine ["covers", "exports"] oldu. Böylece module'ün içinde her seferinde null kontrolü yazmazsın.

Değer Vermek: tfvars Dosyaları ve TF_VAR_

Varsayılanı olmayan bir variable'a değer vermezsen Terraform önce interaktif olarak sorar, CI gibi etkileşimsiz bir ortamda ise durur:

Error: No value for required variable

  on variables.tf line 32:
  32: variable "payments_api_key" {

The root module input variable "payments_api_key" is not set, and has no
default value. Use a -var or -var-file command line argument to provide a
value for this variable.

Ortama özgü değerler bir .tfvars dosyasına yazılır. dev.tfvars:

environment = "dev"

api = {
  image_tag     = "1.1.0"
  external_port = 18480
  app_settings = {
    Logging__LogLevel__Default = "Information"
  }
}

Bu dosya terraform plan -var-file=dev.tfvars ile verilir. Adı terraform.tfvars ya da *.auto.tfvars ile bitseydi Terraform onu kendiliğinden yüklerdi; ortam başına dosyada açık -var-file daha az sürpriz üretir. Ödeme sağlayıcısının sandbox anahtarı gibi secret'lar tfvars'a yazılmaz, çünkü tfvars dosyası commit'lenir. Onlar environment variable'dan gelir: TF_VAR_ önekli her environment variable aynı adlı Terraform variable'ına değer verir.

# PowerShell
$env:TF_VAR_payments_api_key = "<sandbox-key>"

# bash
export TF_VAR_payments_api_key="<sandbox-key>"

Değer birden fazla yerden gelirse öncelik sırası belli: en düşükte environment variable'lar, sonra terraform.tfvars, sonra alfabetik sırayla *.auto.tfvars dosyaları, en üstte de komut satırındaki -var ve -var-file'lar, yazıldıkları sırayla. Validation örneğinde -var-file=dev.tfvars -var 'environment=test' dediğimde test'in kazanması bu yüzden.

Local'ler ve Expression'lar

Variable dışarıdan gelen değer, local ise kodun içinde hesaplanan ara değer. Aynı ifadeyi beş resource'ta tekrar yazmak yerine bir kez local olarak tanımlarsın. Yerel örneğin locals.tf'i:

locals {
  name_prefix = "tfseries-${var.environment}"

  labels = {
    app         = "bookstore"
    environment = var.environment
    managed_by  = "terraform"
  }

  app_settings = merge(
    { Bookstore__Environment = var.environment },
    var.api.app_settings,
  )
}

name_prefix naming convention'ı tek bir yerde topluyor: bütün Docker nesneleri tfseries-dev-... diye adlanacak. merge birden fazla map'i birleştirir, aynı key varsa sağdaki kazanır. Böylece Bookstore__Environment her ortamda kendiliğinden eklenir, tfvars'taki app_settings ise üzerine yazılır.

Expression'ları ve fonksiyonları denemenin en hızlı yolu terraform console. Proje klasöründe terraform console -var-file=dev.tfvars dediğinde variable'lara, local'lere ve state'teki kaynaklara erişebildiğin bir REPL (yazdığın ifadeyi hemen değerlendiren etkileşimli komut satırı) açılır:

> local.name_prefix
"tfseries-dev"
> merge(local.labels, { environment = "prod", owner = "platform" })
{
  "app" = "bookstore"
  "environment" = "prod"
  "managed_by" = "terraform"
  "owner" = "platform"
}
> lookup({ westeurope = "weu", northeurope = "neu" }, "swedencentral", "unknown")
"unknown"
> try(var.api.app_settings["Logging__LogLevel__Default"], "Warning")
"Information"
> try(var.api.app_settings["Missing__Key"], "Warning")
"Warning"
> [for name in var.volume_names : upper(name) if name != "exports"]
[
  "COVERS",
]
> convert({ port = "18480" }, object({ port = number }))
{
  "port" = 18480
}
> var.environment == "prod" ? "always" : "unless-stopped"
"unless-stopped"

lookup map'te key yoksa üçüncü argümanı döner. try sırayla ifadeleri dener ve hata vermeyen ilkinin sonucunu döner; olmayan bir key'e erişmek normalde hata verir, try onu varsayılana çevirir. for expression'ı C#'taki LINQ'in (Language Integrated Query; koleksiyonları sorgulama API'si) Where ve Select'ine karşılık geliyor: köşeli parantezle list, süslü parantezle map üretir, if ile filtreler. Koşullu ifade (condition ? a : b) C# ile aynı.

convert Terraform 1.15 ile geldi ve bir değeri yazdığın tipe açıkça çevirir. Eskiden tonumber, tolist gibi tek tip fonksiyonları kullanılırdı; convert object gibi iç içe tipleri de tek seferde çevirir. Çevrilemeyen bir değerde (convert("abc", number)) "a number is required" hatası verir.

count mu for_each mi?

Aynı tipten birden fazla kaynak için iki meta-argument var. Tahmin sorusundaki volume'leri önce count ile açıyorum:

resource "docker_volume" "data" {
  count = length(var.volume_names)
  name  = "tfseries-dev-${var.volume_names[count.index]}"
}

Apply'dan sonra state'teki adresler docker_volume.data[0], [1] ve [2]. Listeden exports'u silip plan alınca:

  # docker_volume.data[1] must be replaced
-/+ resource "docker_volume" "data" {
      ~ driver      = "local" -> (known after apply)
      - driver_opts = {} -> null
      ~ id          = "tfseries-dev-exports" -> (known after apply)
      ~ mountpoint  = "/var/lib/docker/volumes/tfseries-dev-exports/_data" -> (known after apply)
      ~ name        = "tfseries-dev-exports" -> "tfseries-dev-invoices" # forces replacement
    }

  # docker_volume.data[2] will be destroyed
  # (because index [2] is out of range for count)
  - resource "docker_volume" "data" {
      - driver      = "local" -> null
      - driver_opts = {} -> null
      - id          = "tfseries-dev-invoices" -> null
      - mountpoint  = "/var/lib/docker/volumes/tfseries-dev-invoices/_data" -> null
      - name        = "tfseries-dev-invoices" -> null
    }

Plan: 1 to add, 0 to change, 2 to destroy.

Tahmin sorusunun cevabı: iki kaynak siliniyor, biri oluşturuluyor. count kaynakları sıra numarasıyla tanır. Ortadaki eleman gidince invoices 2. sıradan 1. sıraya kayıyor; Terraform bunu "1 numaranın adı değişti, 2 numara artık yok" diye okuyor. Bu yüzden invoices volume'ü silinip boş olarak yeniden oluşturuluyor; içindeki faturalar gidiyor. Bu plan'ı okumadan apply eden biri, hiç dokunmadığı bir veriyi kaybeder.

Aynı kaynağı for_each ile yazınca:

resource "docker_volume" "data" {
  for_each = toset(var.volume_names)
  name     = "tfseries-dev-${each.key}"
}

Adresler artık docker_volume.data["covers"], ["exports"], ["invoices"]. Aynı silme işleminin planı:

  # docker_volume.data["exports"] will be destroyed
  # (because key ["exports"] is not in for_each map)
  - resource "docker_volume" "data" {
      - driver      = "local" -> null
      - driver_opts = {} -> null
      - id          = "tfseries-dev-exports" -> null
      - mountpoint  = "/var/lib/docker/volumes/tfseries-dev-exports/_data" -> null
      - name        = "tfseries-dev-exports" -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Yalnız silinmesini istediğin kaynak siliniyor. Kural basit: kaynaklar birbirinin kopyasıysa ve sayı tek önemli şeyse (ör. üç özdeş worker) count; her birinin bir kimliği varsa for_each. count'un bugün en çok kullanıldığı yer, bir kaynağı koşula bağlamak: count = var.environment == "prod" ? 1 : 0. for_each map ya da string set'i kabul eder; list verirsen toset() ile çevirmen gerekir. Yerel örnekte variable'ı baştan set(string) tanımladım, bu yüzden main.tf'te toset yok.

dynamic Block ve Output'lar

for_each kaynak üretir; bir kaynağın içindeki tekrar eden block'lar için ise dynamic var. Docker provider'ında label'lar ve volume bağlantıları ayrı block'lar olarak yazılıyor. Yerel örneğin main.tf'i:

provider "docker" {}

resource "docker_network" "bookstore" {
  name = local.name_prefix

  dynamic "labels" {
    for_each = local.labels
    content {
      label = labels.key
      value = labels.value
    }
  }
}

resource "docker_volume" "data" {
  for_each = var.volume_names
  name     = "${local.name_prefix}-${each.key}"

  dynamic "labels" {
    for_each = local.labels
    content {
      label = labels.key
      value = labels.value
    }
  }
}

resource "docker_container" "api" {
  name    = "${local.name_prefix}-api"
  image   = "bookstore-api:${var.api.image_tag}"
  restart = var.environment == "prod" ? "always" : "unless-stopped"

  env = concat(
    [for key, value in local.app_settings : "${key}=${value}"],
    ["Bookstore__Payments__ApiKey=${var.payments_api_key}"],
  )

  dynamic "labels" {
    for_each = local.labels
    content {
      label = labels.key
      value = labels.value
    }
  }

  dynamic "volumes" {
    for_each = docker_volume.data
    content {
      volume_name    = volumes.value.name
      container_path = "/data/${volumes.key}"
    }
  }

  networks_advanced {
    name = docker_network.bookstore.name
  }

  ports {
    internal = 8080
    external = var.api.external_port
  }
}

dynamic "labels" local.labels map'indeki her key/value çifti için bir labels block'u üretir; block içinde iterator'ın adı block'un adıyla aynı (labels.key, labels.value). Container'daki dynamic "volumes" ise doğrudan docker_volume.data'nın üzerinde dönüyor: for_each ile oluşan bir kaynak, key'leri ve instance'larıyla bir map gibi davranır. env ise bir list attribute'u, block değil; onu for expression'ıyla KEY=value string'lerine çeviriyoruz. Plan'ın ilgili kısmı:

      + env                                         = (sensitive value)
...
      + labels {
          + label = "app"
          + value = "bookstore"
        }
      + labels {
          + label = "environment"
          + value = "dev"
        }
      + labels {
          + label = "managed_by"
          + value = "terraform"
        }
...
      + volumes {
          + container_path  = "/data/covers"
          + volume_name     = "tfseries-dev-covers"
            # (3 unchanged attributes hidden)
        }
      + volumes {
          + container_path  = "/data/exports"
          + volume_name     = "tfseries-dev-exports"
            # (3 unchanged attributes hidden)
        }

env satırına dikkat: listedeki elemanlardan biri sensitive bir variable'dan geldiği için Terraform bütün listeyi (sensitive value) olarak gösteriyor. Ama bu yalnız ekrandaki gösterim. State dosyasında değer düz metin olarak duruyor:

$ grep -c "Bookstore__Payments__ApiKey=" terraform.tfstate
1

sensitive'in bir güvenlik sınırı değil, bir gösterim ayarı olduğunu unutma; state'i korumak 4. yazının, secret'ı state'e hiç yazmamak 9. yazının konusu.

Output'lar root module'ün dışarıya verdiği değerler. Apply'dan sonra ekrana basılırlar, terraform output ile tekrar okunurlar ve 6. yazıda göreceğimiz gibi module'ler arası sözleşmenin yarısını oluştururlar:

output "api_url" {
  description = "Health endpoint of the local Bookstore API."
  value       = "http://localhost:${var.api.external_port}/health"
}

output "volume_names" {
  description = "Docker volume names, keyed by data set."
  value       = { for key, volume in docker_volume.data : key => volume.name }
}

output "app_settings" {
  description = "Non-secret settings passed to the API."
  value       = local.app_settings
}

Apply'dan sonra:

$ terraform output
api_url = "http://localhost:18480/health"
app_settings = {
  "Bookstore__Environment" = "dev"
  "Logging__LogLevel__Default" = "Information"
}
volume_names = {
  "covers" = "tfseries-dev-covers"
  "exports" = "tfseries-dev-exports"
}
$ terraform output -json volume_names
{"covers":"tfseries-dev-covers","exports":"tfseries-dev-exports"}
$ curl http://localhost:18480/health
{"status":"Healthy","environment":"dev","version":"1.1.0"}

-json çıktısı script'ler ve pipeline'lar için. Secret içeren bir output'u sensitive = true ile işaretlersen terraform output onu <sensitive> diye gösterir, ama -json ve -raw değeri açık yazar.

Bookstore'un Azure Altyapısı HCL ile

Aynı araçlarla infra/azure klasörünü büyütüyoruz: Linux App Service Plan, container çalıştıran Web App, Azure SQL Server ve BookstoreDb, storage account ve içinde blob container'lar. Provider block'unda kayıt listesine Microsoft.Web ve Microsoft.Sql eklendi. Variable'lar yerel örneğe benziyor; farklı olanlar allowed_ip_ranges (Web App'e erişebilecek IP aralıkları), storage_containers ve SQL yöneticisinin login ve şifresi. Şifre sensitive bir variable ve TF_VAR_sql_admin_password'dan geliyor. Bu da yerel örnekteki gibi state'e düz metin olarak yazılır; 9. yazıda write-only argument'larla bunu kapatacağız. Naming convention local'lerde:

locals {
  region_short = {
    westeurope    = "weu"
    northeurope   = "neu"
    swedencentral = "sdc"
  }

  # <tip>-bookstore-<environment>-<region>, örnek: app-bookstore-dev-weu
  suffix         = "bookstore-${var.environment}-${lookup(local.region_short, var.location, var.location)}"
  compact_suffix = replace(local.suffix, "-", "")

  tags = {
    app         = "bookstore"
    environment = var.environment
    managed_by  = "terraform"
  }

  app_settings = merge(
    { Bookstore__Environment = var.environment },
    var.api.app_settings,
  )
}

lookup burada region'un kısa kodunu buluyor, bulamazsa region adının kendisini kullanıyor. Storage account adı tire kabul etmediği için replace ile tiresiz bir sürüm de üretiliyor. Kaynaklar:

resource "azurerm_resource_group" "bookstore" {
  name     = "rg-${local.suffix}"
  location = var.location
  tags     = local.tags
}

resource "azurerm_storage_account" "bookstore" {
  name                     = "st${local.compact_suffix}"
  resource_group_name      = azurerm_resource_group.bookstore.name
  location                 = azurerm_resource_group.bookstore.location
  account_tier             = "Standard"
  account_replication_type = var.environment == "prod" ? "ZRS" : "LRS"
  min_tls_version          = "TLS1_2"
  tags                     = local.tags
}

resource "azurerm_storage_container" "bookstore" {
  for_each = var.storage_containers

  name                  = each.key
  storage_account_id    = azurerm_storage_account.bookstore.id
  container_access_type = "private"
}

resource "azurerm_service_plan" "bookstore" {
  name                = "asp-${local.suffix}"
  resource_group_name = azurerm_resource_group.bookstore.name
  location            = azurerm_resource_group.bookstore.location
  os_type             = "Linux"
  sku_name            = var.api.sku_name
  tags                = local.tags
}

resource "azurerm_linux_web_app" "api" {
  name                = "app-${local.suffix}"
  resource_group_name = azurerm_resource_group.bookstore.name
  location            = azurerm_resource_group.bookstore.location
  service_plan_id     = azurerm_service_plan.bookstore.id
  https_only          = true
  app_settings        = local.app_settings
  tags                = local.tags

  site_config {
    minimum_tls_version               = "1.2"
    health_check_path                 = "/health"
    health_check_eviction_time_in_min = 5
    ip_restriction_default_action     = length(var.allowed_ip_ranges) > 0 ? "Deny" : "Allow"

    application_stack {
      docker_image_name   = "${var.api.image_name}:${var.api.image_tag}"
      docker_registry_url = var.api.registry_url
    }

    dynamic "ip_restriction" {
      for_each = var.allowed_ip_ranges
      content {
        name       = ip_restriction.key
        ip_address = ip_restriction.value
        action     = "Allow"
        priority   = 100
      }
    }
  }
}

resource "azurerm_mssql_server" "bookstore" {
  name                         = "sql-${local.suffix}"
  resource_group_name          = azurerm_resource_group.bookstore.name
  location                     = azurerm_resource_group.bookstore.location
  version                      = "12.0"
  administrator_login          = var.sql_admin_login
  administrator_login_password = var.sql_admin_password
  minimum_tls_version          = "1.2"
  tags                         = local.tags
}

resource "azurerm_mssql_database" "bookstore" {
  name      = "BookstoreDb"
  server_id = azurerm_mssql_server.bookstore.id
  sku_name  = var.environment == "prod" ? "S1" : "Basic"
  tags      = local.tags
}

Birkaç noktaya dikkat. Blob container'lar for_each ile oluşturuluyor; AzureRM 5.x'te azurerm_storage_container storage account'u adıyla değil storage_account_id ile buluyor. Web App'in site_config'indeki dynamic "ip_restriction" bloğu, allowed_ip_ranges map'indeki her girdi için bir erişim kuralı üretiyor; map boşsa hiç kural oluşmuyor ve varsayılan aksiyon Allow kalıyor. Conditional'lar ortam farklarını taşıyor: production'da storage ZRS (veriyi aynı region'daki üç availability zone'a kopyalayan replikasyon), veritabanı S1; diğer ortamlarda LRS ve Basic. Web App'in app_settings'i, yerel örnekteki container env'iyle aynı rolü oynuyor; ASP.NET Core Bookstore__Environment'ı configuration'da Bookstore:Environment olarak okur.

Output'lar API'nin adresini, SQL Server'ın host adını ve container adlarını veriyor:

output "api_url" {
  description = "Public URL of the Bookstore API."
  value       = "https://${azurerm_linux_web_app.api.default_hostname}"
}

output "sql_server_fqdn" {
  description = "Host name for the BookstoreDb connection string."
  value       = azurerm_mssql_server.bookstore.fully_qualified_domain_name
}

output "storage_containers" {
  description = "Blob container names, keyed by data set."
  value       = { for key, container in azurerm_storage_container.bookstore : key => container.name }
}

terraform validate "Success! The configuration is valid." diyor. Azure'a plan alınmadı; App Service'in ve Azure SQL'in fiyat katmanları, SKU (Azure'da bir service'in boyut ve fiyat katmanı) seçimi ve region kısıtları bu serinin değil Cloud Temelleri serisinin konusu.

Üretimde Dikkat

Variable'ları bir sözleşme gibi tasarla. Her variable'a description yaz, tipini olabildiğince dar tut (serbest bir map(any) yerine alanları belli bir object) ve geçerli değer aralığını validation ile koru. Yanlış bir değerin plan'dan önce reddedilmesi, Azure'da yarım kalmış bir apply'dan çok daha ucuz.

Naming convention'ı local'lerde tek yerde tut ve kaynak adlarını ortamdan türet. Azure'da birçok kaynağın adı sonradan değiştirilemiyor; değiştirmek replace demek. Adı ilk günden doğru koymak, sonradan veri taşımaktan kolay.

Secret'ları tfvars'a değil environment variable'a koy ve CI log'larında görünmediğinden emin ol. sensitive ekranda gizler, state'te gizlemez.

Sık Düşülen Tuzaklar

count ile oluşturulan bir listeden ortadaki elemanı silmek. Yukarıda gördüğün gibi sonraki her eleman bir sıra kayar ve replace edilir. Kimliği olan her şey için for_each kullan. Var olan bir count kaynağını for_each'e çevirirken de adresler değişir; bunu kaynakları silmeden yapmak için 8. yazıdaki moved block'ları gerekir.

for_each'e plan anında bilinmeyen bir değer vermek. Key'ler apply sırasında oluşacak bir kaynağın ID'sinden geliyorsa Terraform hangi instance'ları oluşturacağını bilemez ve "Invalid for_each argument" hatası verir. Key'ler her zaman kodda ya da variable'da bilinen değerler olmalı.

Her şeyi variable yapmak. Hiçbir ortamda değişmeyen bir değeri (ör. account_tier = "Standard") variable'a çevirmek kodu esnek yapmaz, yalnız okumayı zorlaştırır. Ortamdan ortama gerçekten değişen değerler variable olur, gerisi kodda kalır.

Küçük Bir Deneme

★☆☆ terraform console -var-file=dev.tfvars aç ve { for k, v in local.labels : upper(k) => v } ifadesinin sonucunu tahmin et, sonra çalıştır.

★★☆ dev.tfvars'ın yanına bir staging.tfvars yaz (external_port = 18481, environment = "staging") ve terraform plan -var-file=staging.tfvars çalıştır. Plan neden var olan dev kaynaklarını değiştirmek ya da yeniden oluşturmak istiyor? Bu sorunun cevabı 7. yazının konusu.

★★★ volume_names'e üçüncü bir volume (invoices) ekle ve plan al. Container neden replace ediliyor? dynamic "volumes" block'unun container üzerindeki etkisini plan çıktısından bul.

Bir sonraki yazıda bu yazının birkaç kez ertelediği konuya, state'e geçiyoruz: state dosyasının içinde ne var, neden laptop'ta durmamalı, bir remote backend'e nasıl taşınır, iki kişi aynı anda apply ettiğinde locking ne yapar ve state mv, state rm, -refresh-only ne zaman gerekir.