"Hangi IaC aracını kullanmalıyız?" tek bir soru gibi görünür, ama içinde üç ayrı karar vardır: bulut kaynaklarını hangi araç oluşturacak (provisioning), sunucunun içini hangi araç yapılandıracak (configuration management) ve bu adımları kim, hangi sırayla çalıştıracak (orkestrasyon). Bu rehberde araçları bu ayrıma göre karşılaştırıyor, Terraform'un lisans değişikliğinden sonraki durumu kaynağından doğrulanmış tarihlerle özetliyor ve uçtan uca bir örnek kuruyoruz: OpenTofu (ya da Terraform) bir sanal sunucu, güvenlik duvarı ve DNS kaydı oluşturuyor; Ansible da kullanıcıyı ve SSH anahtarını ekliyor, paketleri ve Nginx'i kuruyor. Ardından state yönetimi, sapma (drift) tespiti, test, OIDC, OpenTofu'ya geçiş ve mevcut sunucuların koda alınması geliyor. Sürüm bilgileri Ekim 2026 itibarıyla kontrol edildi (Terraform 1.17, OpenTofu 1.13).
Üç katman: provisioning, yapılandırma ve orkestrasyon
- Provisioning (altyapı oluşturma): Sağlayıcının API'si üzerinden sunucu, ağ, güvenlik duvarı (firewall), DNS kaydı, bucket veya yönetilen veritabanı açmak. İstenen durumu (desired state) tarif edersiniz; araç gerçek durumla karşılaştırıp bir plan çıkarır ve yalnızca farkı uygular. Terraform, OpenTofu, Pulumi, CloudFormation/CDK, Bicep ve Crossplane bu katmandadır.
- Yapılandırma yönetimi: Kullanıcılar, anahtarlar, paketler, dosyalar ve servisler gibi sunucunun içindeki her şey. Görevler idempotent yazılır; değişecek bir şey yoksa ikinci çalıştırma hiçbir şeye dokunmaz. Ansible, Salt, Chef ve Puppet buradadır.
- Orkestrasyon: Adımların sırası (plan, onay, apply, yapılandırma, doğrulama) ve aralarındaki onay kapıları. Bu iş çoğunlukla CI/CD hattına düşer; çok sayıda state yöneten ekipler Terragrunt, Atlantis ya da Spacelift, env0, Scalr gibi platformlar da kullanır. Kubernetes'in konteyner orkestrasyonu ayrı bir karardır; onun için Docker, Podman ve Kubernetes yazımıza bakabilirsiniz.
Karar tablosu: hangi araç, hangi iş
| Araç | Katman | Dil | Durum modeli | Lisans | En iyi uyduğu yer |
|---|---|---|---|---|---|
| Terraform | Provisioning | HCL | State dosyası | BUSL 1.1 (1.6.0 ve sonrası) | Çok sağlayıcılı ortamlar, HCP Terraform kullananlar |
| OpenTofu | Provisioning | HCL | State dosyası, isteğe bağlı istemci tarafı şifreleme | MPL 2.0 | Açık kaynak lisans şartı, Terraform'dan geçiş |
| Pulumi | Provisioning | TypeScript, Python, Go, C#, Java, YAML | Pulumi Cloud ya da S3, Blob, GCS, PostgreSQL | Apache 2.0 (CLI, SDK) | Altyapıyı uygulama diliyle ve testli yazmak isteyen ekipler |
| CloudFormation / CDK | Provisioning (AWS) | YAML/JSON; CDK ile TypeScript, Python vb. | Stack'i AWS tutar | AWS hizmeti; CDK Apache 2.0 | Yalnızca AWS kullananlar |
| Bicep | Provisioning (Azure) | Bicep, ARM JSON'a derlenir | State dosyası yok | MIT | Yalnızca Azure kullananlar |
| Crossplane | Provisioning (Kubernetes ile) | YAML (CRD) | Sürekli uzlaştırma (reconcile) | Apache 2.0 | Kubernetes merkezli platform ekipleri |
| Ansible | Yapılandırma | YAML | State yok, ajansız (SSH) | GPLv3 | Varsayılan seçim, küçük ve orta filolar |
| Salt | Yapılandırma | YAML + Jinja | Master/minion, olay veri yolu | Apache 2.0 | Büyük filolar, olay güdümlü otomasyon |
| Chef | Yapılandırma | Ruby DSL | Sunucu + ajan (pull) | Kod Apache 2.0; paketler EULA'lı | Mevcut Chef kurulumları |
| Puppet | Yapılandırma | Puppet DSL | Sunucu + ajan (pull) | Kod Apache 2.0; yeni paketler EULA'lı | Mevcut, uyumluluk ağırlıklı kurulumlar |
- Sunucu bir firmada, DNS Cloudflare'de, yedek S3'te gibi çok sağlayıcılı bir düzen: Terraform veya OpenTofu.
- Ekip döngüleri ve birim testlerini bildiği dilde yazmak istiyor: Pulumi. Bedeli: altyapı kodu da uygulama kodu gibi bağımlılık yönetimi ve kod incelemesi disiplini ister.
- Yalnızca AWS ya da yalnızca Azure kullanıyorsunuz ve state dosyasıyla uğraşmak istemiyorsunuz: CloudFormation/CDK veya Bicep. İleride ikinci bir bulut ihtimali varsa HCL ile başlamak sonraki geçişi ucuzlatır.
- Platform Kubernetes üzerinde ve elle yapılan değişikliğin otomatik geri alınması isteniyor: Crossplane (Ekim 2025'te CNCF'de graduated seviyesine geçti).
- Sunucu içinde varsayılan Ansible; binlerce sunucu ve olay güdümlü tepki için Salt. Chef ve Puppet'ı çoğunlukla mevcut yatırımı olan ekipler sürdürür.
Lisans ve yönetişim: Terraform, OpenTofu ve IBM
- 10 Ağustos 2023: HashiCorp, ürünlerinin gelecek sürümlerini Mozilla Public License 2.0 yerine Business Source License 1.1 (BUSL, BSL olarak da yazılır) ile yayımlayacağını duyurdu; API'ler, SDK'lar ve kütüphanelerin çoğu MPL 2.0'da kaldı. Terraform deposundaki LICENSE dosyası lisanslı eseri "Terraform 1.6.0 ve sonrası" olarak tanımlıyor; 1.5.x ve öncesi MPL 2.0 olarak kaldı.
- BUSL neyi kısıtlıyor? Ek kullanım izni (Additional Use Grant) üretimde kullanıma izin veriyor. Yasak olan, Terraform'u üçüncü taraflara barındırılan (hosted) veya gömülü (embedded) biçimde sunarak lisans verenin (bugün IBM'in) ücretli sürümleriyle rekabet etmek. Lisans, rakip ürünü (competitive offering) bu ücretli sürümlerle önemli ölçüde örtüşen ücretli bir ürün olarak tanımlıyor; Terraform'u kuruluş içinde barındırmak veya kullanmak açıkça rakip ürün sayılmıyor. Her sürüm, yayımlanmasından dört yıl sonra MPL 2.0 lisansına geçiyor. Etkilenenler başta Terraform tabanlı ticari hizmet sunan firmalar ve yalnızca OSI onaylı lisans kabul eden kurumlar.
- 20 Eylül 2023: Linux Foundation, MPL 2.0 lisanslı Terraform'un açık kaynak devamı olarak OpenTofu'yu duyurdu. OpenTofu MPL 2.0 ile dağıtılıyor; ilk kararlı sürüm 1.6.0 Ocak 2024'te çıktı ve proje 23 Nisan 2025'te CNCF'ye Sandbox seviyesinde kabul edildi.
- 27 Şubat 2025: IBM, 2024'te duyurduğu HashiCorp satın alımını tamamladı; Terraform'un LICENSE dosyasında lisans veren artık IBM. Terraform Cloud da 22 Nisan 2024'ten beri HCP Terraform adını taşıyor.
Yapılandırma tarafında da sahiplik değişti. Ansible GPLv3 lisanslı ve geliştirmesini IBM'in sahibi olduğu Red Hat yönetiyor; yani Terraform ile Ansible bugün aynı şirketin çatısı altında. Salt Apache 2.0 lisanslı ve Broadcom bünyesinde. Chef'in kaynak kodu Apache 2.0, ama Progress'in dağıttığı paketler Chef EULA'sının kabulünü istiyor ve ücretsiz lisans yalnızca üretim dışı kullanımı kapsıyor. Puppet'ın sahibi Perforce, 7 Kasım 2024'te yeni ikili paketleri 2025'ten itibaren özel bir depodan, geliştirme amaçlı kullanımda 25 düğüme kadar ücretsiz bir EULA ile dağıtacağını duyurdu; kaynak kodu Apache 2.0 kaldı ve topluluk OpenVox çatalını başlattı. Seçimden önce şunları netleştirin: ikili dosyayı kim hangi lisansla dağıtıyor, kullanım kuruluş içi mi yoksa müşteriye sunulan bir hizmetin parçası mı, kurumda yalnızca açık kaynak politikası var mı. Bu bölüm hukuki görüş değildir; ticari bir üründe lisans metnini hukuk ekibinizle okuyun.
Uçtan uca örnek: OpenTofu ile sunucu, güvenlik duvarı ve DNS; Ansible ile sunucunun içi
Örnekte DigitalOcean sağlayıcısını seçtik, çünkü sunucu, güvenlik duvarı ve DNS kaydı tek sağlayıcıda az satırla gösterilebiliyor; alan adının DNS'inin orada barındırıldığını varsayıyoruz. Değerler yer tutucudur (example.com ve 198.51.100.10 gibi dokümantasyon için ayrılmış adresler). Kod hem tofu hem terraform ile çalışır; admin_cidr ve ssh_public_key değerlerini terraform.tfvars dosyasında verin, ikisi de gizli bilgi değildir.1. Adım: Altyapıyı tanımlayın (HCL)
Code:
# infra/main.tf
terraform {
required_version = ">= 1.6"
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.0"
}
}
}
# Token koda yazılmaz: sağlayıcı DIGITALOCEAN_TOKEN ortam değişkenini okur.
provider "digitalocean" {}
variable "admin_cidr" {
type = string # ör. 198.51.100.10/32
validation {
condition = can(cidrhost(var.admin_cidr, 0))
error_message = "admin_cidr geçerli bir CIDR olmalı."
}
}
variable "ssh_public_key" {
type = string
}
variable "domain" {
type = string
default = "example.com"
}
resource "digitalocean_ssh_key" "admin" {
name = "admin"
public_key = var.ssh_public_key
}
resource "digitalocean_droplet" "web" {
name = "web-01"
region = "fra1"
size = "s-1vcpu-2gb"
image = "ubuntu-24-04-x64"
ssh_keys = [digitalocean_ssh_key.admin.fingerprint]
tags = ["web"]
}
resource "digitalocean_firewall" "web" {
name = "web-fw"
droplet_ids = [digitalocean_droplet.web.id]
inbound_rule {
protocol = "tcp"
port_range = "22"
source_addresses = [var.admin_cidr]
}
inbound_rule {
protocol = "tcp"
port_range = "80"
source_addresses = ["0.0.0.0/0", "::/0"]
}
inbound_rule {
protocol = "tcp"
port_range = "443"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# Kuralla izin verilmeyen trafik engellenir; giden kuralları da yazın.
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "udp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "icmp"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
}
resource "digitalocean_record" "www" {
domain = var.domain
type = "A"
name = "www"
value = digitalocean_droplet.web.ipv4_address
ttl = 300
}
output "web_ipv4" {
value = digitalocean_droplet.web.ipv4_address
}
- DigitalOcean belgelerine göre bulut güvenlik duvarı, kuralla açıkça izin verilmeyen bütün trafiği engeller. Giden (outbound) kuralları yazmazsanız apt ve DNS çözümlemesi takılır.
- Sağlayıcı belgesine göre ssh_keys veya user_data sonradan değişirse sunucu silinip yeniden oluşturulur. İlk anahtarı burada verin, sonraki anahtar değişikliklerini Ansible'a bırakın.
- Kuralları dynamic bloklarla kısaltmak mümkündü; bilerek açık yazdık. Açık, ayrıntılı ve "sıkıcı" IaC'nin bakımı, altı ay sonra okuyan biri için zekice kısaltılmış koddan çok daha kolaydır.
2. Adım: Planlayın, uygulayın, envanteri üretin
Bash:
cd infra
export DIGITALOCEAN_TOKEN="..." # CI'da secret deposundan gelir
tofu init
tofu plan -out=web.tfplan # incelediğiniz plan dosyaya yazılır
tofu apply web.tfplan # yalnızca o plan uygulanır
IP="$(tofu output -raw web_ipv4)"
for i in $(seq 30); do # sshd yanıt verene kadar bekle (en fazla ~2,5 dk)
ssh-keyscan "$IP" 2>/dev/null | grep -q . && break
sleep 5
done
ssh-keyscan -H "$IP" >> ~/.ssh/known_hosts # parmak izini doğrulayın (aşağıya bakın)
printf '[web]\n%s ansible_user=root\n' "$IP" > ../ansible/inventory.ini
cd ../ansible
ansible-galaxy collection install ansible.posix
ansible-playbook -i inventory.ini site.yml
3. Adım: Sunucuyu Ansible ile yapılandırın
YAML:
# ansible/site.yml
- name: Web sunucusunu yapılandır
hosts: web
become: true
gather_facts: false
vars:
deploy_user: deploy
site_domain: www.example.com
tasks:
- name: SSH erişimi açılana kadar bekle
ansible.builtin.wait_for_connection:
timeout: 300
- name: cloud-init işini bitirsin (apt kilidi çakışmasın)
ansible.builtin.command: cloud-init status --wait
register: ci
changed_when: false
failed_when: ci.rc not in [0, 2] # 2 = kurtarılabilir hatayla bitti
- name: Paketleri kur
ansible.builtin.apt:
name: [nginx, unattended-upgrades]
state: present
update_cache: true
cache_valid_time: 3600
- name: Deploy kullanıcısı
ansible.builtin.user:
name: "{{ deploy_user }}"
groups: sudo
append: true
shell: /bin/bash
- name: Deploy kullanıcısının SSH anahtarı
ansible.posix.authorized_key:
user: "{{ deploy_user }}"
key: "{{ lookup('ansible.builtin.file', 'files/deploy.pub') }}"
exclusive: true
- name: Parolasız sudo (visudo ile doğrulanır)
ansible.builtin.copy:
dest: "/etc/sudoers.d/90-{{ deploy_user }}"
content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
mode: "0440"
validate: /usr/sbin/visudo -csf %s
- name: SSH sıkılaştırma (00- öneki, ilk okunan değer geçerli)
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/00-hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: reload ssh
- name: Nginx site yapılandırması
ansible.builtin.template:
src: templates/site.conf.j2
dest: /etc/nginx/sites-available/site.conf
mode: "0644"
notify: reload nginx
- name: Siteyi etkinleştir
ansible.builtin.file:
src: /etc/nginx/sites-available/site.conf
dest: /etc/nginx/sites-enabled/site.conf
state: link
notify: reload nginx
handlers:
- name: SSH servisini yeniden yükle
ansible.builtin.service:
name: ssh
state: reloaded
listen: reload ssh
- name: Nginx yapılandırmasını test et
ansible.builtin.command: nginx -t
changed_when: false
listen: reload nginx
- name: Nginx'i yeniden yükle
ansible.builtin.service:
name: nginx
state: reloaded
listen: reload nginx
Code:
# ansible/templates/site.conf.j2 (TLS bu yazının kapsamı dışında)
server {
listen 80;
listen [::]:80;
server_name {{ site_domain }};
root /var/www/html;
index index.html index.nginx-debian.html;
}
- Playbook'un yanında iki dosya bekleniyor: files/deploy.pub (deploy kullanıcısının açık anahtarı) ve yukarıdaki templates/site.conf.j2.
- Yeni sunucuda cloud-init arka planda apt çalıştırıyor olabilir; "Could not get lock" hatalarının çoğu buradan gelir. cloud-init status --wait cloud-init işini bitirene kadar bekler; kurtarılabilir hatalarla bittiğinde 2 döndürdüğü için 0 ve 2'yi kabul ettik, 2 görürseniz /var/log/cloud-init.log dosyasına bakın.
- sshd her anahtar sözcük için ilk okunan değeri kullanır ve Include edilen dosyaları sözlük sırasıyla işler. Bazı bulut imajları sshd_config.d altına PasswordAuthentication içeren bir dosya (ör. 50-cloud-init.conf) koyar; dosya adını 00- ile başlatmanın nedeni bu.
- Root girişi bu çalıştırmanın sonunda kapanır. Sonraki çalıştırmadan önce envanterde ansible_user=deploy yapın ve girişi ayrı bir terminalde, açık oturumu kapatmadan test edin.
- Debian ve Ubuntu'da SSH servisinin adı ssh, RHEL ailesinde sshd'dir. RHEL tabanlı sistemlerde ayrıca yönetici grubu sudo yerine wheel'dir, apt yerine dnf modülü kullanılır ve Nginx sites-enabled yerine /etc/nginx/conf.d dizinini okur.
- İkinci çalıştırmada changed=0 görmelisiniz. Görmüyorsanız bir görev idempotent değildir; command ve shell görevlerine creates veya changed_when ekleyin.
Aynı altyapı Pulumi ile (Python)
Pulumi'de kaynaklar Python nesneleridir ve tekrar eden kurallar sıradan bir list comprehension ile üretilir. Paketler pip install pulumi pulumi-digitalocean ile kurulur; sağlayıcı aynı DIGITALOCEAN_TOKEN değişkenini ya da digitalocean:token ayarını okur.
Python:
# __main__.py
import pulumi
import pulumi_digitalocean as do
cfg = pulumi.Config()
admin_cidr = cfg.require("adminCidr")
domain = cfg.get("domain") or "example.com"
anywhere = ["0.0.0.0/0", "::/0"]
key = do.SshKey("admin", public_key=cfg.require("sshPublicKey"))
web = do.Droplet(
"web",
image="ubuntu-24-04-x64",
region="fra1",
size="s-1vcpu-2gb",
ssh_keys=[key.fingerprint],
tags=["web"],
)
inbound = [{"protocol": "tcp", "port_range": "22", "source_addresses": [admin_cidr]}]
inbound += [
{"protocol": "tcp", "port_range": port, "source_addresses": anywhere}
for port in ("80", "443")
]
outbound = [
{"protocol": proto, "port_range": "1-65535", "destination_addresses": anywhere}
for proto in ("tcp", "udp")
]
outbound.append({"protocol": "icmp", "destination_addresses": anywhere})
do.Firewall(
"web",
droplet_ids=[web.id.apply(int)], # kimlik metin, alan sayı bekler
inbound_rules=inbound,
outbound_rules=outbound,
)
do.DnsRecord("www", domain=domain, type="A", name="www",
value=web.ipv4_address, ttl=300)
pulumi.export("web_ipv4", web.ipv4_address)
Diğer sağlayıcılarda karşılıklar
| Sağlayıcı | Sunucu | Güvenlik duvarı | DNS kaydı |
|---|---|---|---|
| DigitalOcean | digitalocean_droplet | digitalocean_firewall | digitalocean_record |
| Hetzner Cloud | hcloud_server | hcloud_firewall | hcloud_zone_rrset |
| AWS | aws_instance | aws_security_group | aws_route53_record |
| Azure | azurerm_linux_virtual_machine | azurerm_network_security_group | azurerm_dns_a_record |
| Google Cloud | google_compute_instance | google_compute_firewall | google_dns_record_set |
| Cloudflare (yalnız DNS) | - | - | cloudflare_dns_record (v5; v4'te cloudflare_record) |
State yönetimi: remote backend, kilitleme ve şifreleme
State, koddaki her kaynağın gerçek karşılığını (kimlik, IP, öznitelikler) tutan JSON dosyasıdır. Kaybolursa araç kaynaklarınızı tanımaz, iki kişi aynı anda yazarsa bozulur ve içinde gizli bilgiler bulunur. Bu yüzden ilk günden uzak depo (remote backend), kilit, sürümleme ve erişim kısıtı gerekir.- Depo: S3 ve S3 uyumlu depolar, GCS, Azure Blob, PostgreSQL, GitLab'ın HTTP backend'i veya HCP Terraform. Pulumi'de Pulumi Cloud ya da pulumi login ile s3://, azblob://, gs://, postgres://; bu DIY backend'lerde dosya tabanlı basit bir kilit varsayılan olarak açıktır.
- Kilit: Terraform 1.10, S3 backend'e S3'ün kendi kilidini (use_lockfile) getirdi; 1.11'de özellik genel kullanıma açıldı ve DynamoDB argümanları kullanımdan kaldırılacak (deprecated) olarak işaretlendi. Kilit, state anahtarının yanına .tflock nesnesi olarak yazılır; rolün bu nesnede s3:GetObject, s3
utObject ve s3
eleteObject iznine ihtiyacı var. OpenTofu aynı özelliği 1.10'da ekledi; belgeleri kilidin If-None-Match koşullu yazmasıyla alındığını ve DynamoDB'yi kaldırma planı olmadığını söylüyor. Geçişte bir süre iki kilidi birlikte kullanın, sonra dynamodb_table'ı kaldırıp init -reconfigure çalıştırın. MinIO, Ceph gibi S3 uyumlu depolarda koşullu yazma desteğini önce test edin. - Sürümleme ve takılı kilit: Bucket'ta nesne sürümlemeyi açın; bozulan state'i geri almanın en kısa yolu budur. Yarıda kesilen bir iş kilidi geride bırakabilir; force-unlock'u yalnızca o işin bittiğinden emin olunca kullanın.
Code:
terraform {
backend "s3" {
bucket = "acme-iac-state"
key = "prod/web.tfstate"
region = "eu-central-1"
encrypt = true
use_lockfile = true # Terraform >= 1.10, OpenTofu >= 1.10
}
}
- Backend'de durağan şifreleme (encryption at rest), dar erişim yetkisi ve denetim kaydı.
- Ephemeral değerler ve write-only argümanlar (Terraform 1.10/1.11, OpenTofu 1.11): sağlayıcı destekliyorsa değer state'e ve plana hiç yazılmaz.
- OpenTofu 1.7 ile gelen istemci tarafı şifreleme: state ve plan backend'e gitmeden AES-GCM ile şifrelenir; anahtar PBKDF2 parolası, AWS KMS, GCP KMS veya OpenBao'dan gelir, güncel belgelerde Azure Key Vault ve harici program da var.
- Pulumi'de --secret değerler ve onlardan türeyen çıktılar seçilen secrets provider ile (passphrase, awskms, azurekeyvault, gcpkms, hashivault veya Pulumi Cloud) şifreli tutulur.
Code:
variable "state_passphrase" {
type = string
sensitive = true # en az 16 karakter; ör. TF_VAR_state_passphrase ile verin
}
terraform {
encryption {
key_provider "pbkdf2" "main" {
passphrase = var.state_passphrase
}
method "aes_gcm" "main" {
keys = key_provider.pbkdf2.main
}
state {
method = method.aes_gcm.main
enforced = true
}
plan {
method = method.aes_gcm.main
enforced = true
}
}
}
Sapma tespiti, modül sürümleme ve test
Sapma (drift), gerçek altyapının koddan ayrılmasıdır: konsoldan yapılan "hızlı" değişiklikler, başka araçların dokunduğu alanlar (otomatik ölçekleme, güvenlik betikleri), sağlayıcı varsayılanlarındaki değişiklikler. En basit tespit, ana daldaki kodla her gece plan çalıştırmaktır. -detailed-exitcode ile plan 0 (fark yok), 1 (hata) veya 2 (fark var) koduyla çıkar; kod zaten uygulanmışsa 2, birinin altyapıyı kod dışından değiştirdiği anlamına gelir.
YAML:
# .github/workflows/drift.yml
name: drift
on:
schedule:
- cron: "17 3 * * *" # her gece 03:17 UTC
workflow_dispatch: {}
permissions:
contents: read
id-token: write # OIDC belirteci için şart
jobs:
drift:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v7
- uses: opentofu/setup-opentofu@v2
with:
tofu_wrapper: false # ham çıkış kodu için
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::111122223333:role/iac-readonly
aws-region: eu-central-1
- name: Plan ve sapma kontrolü
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DO_TOKEN_READONLY }}
run: |
tofu init -input=false
code=0
tofu plan -input=false -lock-timeout=5m -detailed-exitcode -no-color > plan.txt || code=$?
cat plan.txt
if [ "$code" -eq 2 ]; then echo "::error::Drift: altyapı koddan farklı"; fi
exit "$code"
Modül sürümleme
- Modülleri semver etiketleriyle yayımlayın; geriye uyumsuz değişiklik (breaking change) ana sürümü (major) artırır ve CHANGELOG'a yazılır.
- Ortamlar modüle sabit sürümle bağlanır: git kaynağında ?ref=v1.4.0, registry modülünde version = "~> 1.4".
- Sağlayıcı sürümlerini required_providers içinde sınırlayın ve .terraform.lock.hcl dosyasını commit edin; yoksa iki makine farklı sağlayıcı sürümleriyle plan çıkarabilir.
- Güncellemeleri Renovate veya Dependabot ile PR olarak alın ve plan çıktısıyla inceleyin.
- State'i ortam ve bileşen bazında bölün (prod/network, prod/web gibi); tek ve devasa bir state her planı yavaşlatır, hatanın etki alanını büyütür.
Test katmanları
- Statik: tofu fmt -check, tofu validate, tflint; güvenlik taraması için Checkov veya Trivy.
- Birim: Terraform 1.6 ile genel kullanıma açılan terraform test ve OpenTofu'daki tofu test, .tftest.hcl dosyalarındaki run bloklarını çalıştırır; mock_provider (Terraform 1.7, OpenTofu 1.8) ile bulut hesabı gerekmez.
- Entegrasyon: Terratest (Go) gerçek kaynak açar, doğrular ve siler; süresi ve maliyeti yüzünden gece ya da sürüm öncesi çalıştırın.
- Ansible: ansible-lint, --check --diff ve Molecule. Molecule'ün varsayılan test sırası bir test makinesi (instance) oluşturur, rolü uygular (converge), ikinci çalıştırmada değişiklik olmadığını doğrular (idempotence), doğrulamaları çalıştırır ve makineyi siler.
Code:
# infra/tests/web.tftest.hcl (mock sağlayıcı: token gerekmez)
mock_provider "digitalocean" {}
variables {
admin_cidr = "198.51.100.10/32"
ssh_public_key = "ssh-ed25519 AAAAexample test"
}
run "ssh_yalnizca_yonetici_adresine_acik" {
command = plan
assert {
condition = alltrue([
for r in digitalocean_firewall.web.inbound_rule :
r.port_range != "22" || toset(r.source_addresses) == toset([var.admin_cidr])
])
error_message = "22/tcp yalnızca admin_cidr adresine açık olmalı."
}
}
CI/CD: OIDC ile kısa ömürlü kimlik bilgileri ve kod olarak politika
CI'nin secret deposunda duran uzun ömürlü bir bulut anahtarı sızdığında aylarca geçerli kalır. OIDC ile her iş (job) CI sağlayıcısından imzalı bir belirteç alır, bulut sağlayıcısı belirteci rolün güven koşullarıyla karşılaştırır ve kısa ömürlü kimlik bilgisi verir. GitHub Actions'ta permissions altında id-token: write gerekir; AWS'de kimlik sağlayıcı adresi https://token.actions.githubusercontent.com, hedef kitle (audience) sts.amazonaws.com'dur. GitLab CI'da aynı iş id_tokens anahtar sözcüğüyle yapılır; Azure ve Google Cloud da iş yükü kimlik federasyonunu (workload identity federation) destekler.
Code:
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/infra:environment:production"
}
}
- PR açılınca fmt, validate, test ve salt okunur rolle plan; plan özeti PR'a yorum olarak düşer.
- Birleştirmeden sonra aynı commit için plan -out ile dosyaya yazılır, production ortamındaki onaycı (GitHub environments, required reviewers) onaylar ve apply yalnızca o dosyayı uygular.
- Apply sonrasında Ansible, admin_cidr'in izin verdiği bir makineden çalışır (self-hosted runner veya bastion sunucusu; 22 numaralı portu bütün barındırılan runner'lara açmayın), ardından bir duman testi (smoke test; ör. curl ile HTTP 200 beklemek) gelir.
- Plan dosyaları da gizli bilgi içerebilir; CI artifact'lerinin saklama süresini kısa tutun.
Kod olarak politika (policy as code), planı bir insan incelemeden önce kurallara göre denetler. Açık kaynak tarafta Open Policy Agent (OPA) ve Rego yaygındır: tofu show -json çıktısı Conftest ile "22/tcp 0.0.0.0/0'a açılamaz" ya da "her kaynakta owner etiketi olmalı" gibi kurallara karşı test edilir. HCP Terraform ve Terraform Enterprise'da HashiCorp'un Sentinel dili ve OPA desteklenir. HashiCorp'un Terraform policy belgeleri HCL tabanlı yeni politika çerçevesi için hâlâ beta uyarısı taşıyor; Terraform 1.17 sürüm notları ise Terraform Policy'nin genel kullanıma açıldığını ve plan, apply ve query komutlarındaki -policies bayrağının artık deneysel derleme gerektirmediğini yazıyor. Kullanmadan önce güncel durumu kontrol edin. OPA ve Conftest araçtan bağımsızdır; hem Terraform hem OpenTofu planlarıyla çalışır.
Bash:
tofu show -json web.tfplan > plan.json
conftest test plan.json --policy policy/
Terraform'dan OpenTofu'ya geçiş
Geçiş şu durumlarda mantıklıdır: kurum açık kaynak lisans istiyorsa, state şifrelemeye ihtiyaç varsa ya da topluluk yönetişimindeki bir araç tercih ediliyorsa. HCP Terraform'un uzak çalıştırma, Sentinel ve özel registry özelliklerine yaslanıyorsanız iş komut adını değiştirmekten ibaret değildir; o platformun yerine bir şey koymanız gerekir. OpenTofu'nun geçiş rehberindeki adımlar:- Terraform ile plan çalıştırıp "No changes" görün; state'i yedekleyin, değişiklikleri commit edin ve ayrı bir dal açın.
- OpenTofu'yu kurun, tofu --version ile doğrulayın.
- tofu init: sağlayıcılar OpenTofu registry'sinden iner, backend başlatılır. .terraform.lock.hcl değişirse farkı inceleyip commit edin.
- tofu plan: "No changes" ya da Terraform'un vereceği planın aynısını görmelisiniz; beklenmedik fark varsa apply etmeyin.
- tofu apply: altyapı değişmese de state biçimi güncellenebilir.
- Kritik olmayan küçük bir değişiklikle (ör. bir etiket) döngüyü deneyin.
- Geri dönüş gerekirse state'i yedekten geri yükleyin, terraform init ve terraform plan ile fark olmadığını doğrulayın.
Mevcut sunucuları koda almak (import)
Elle kurulmuş kaynakları yeniden oluşturmadan IaC'ye almak mümkün:- Kaynakları ve kimliklerini listeleyin (ör. doctl compute droplet list). Terraform 1.14'teki terraform query, list kaynaklarını destekleyen sağlayıcılarda mevcut kaynakları bulup import yapılandırması üretebiliyor.
- Her kaynak için bir import bloğu yazın. Bu blok Terraform'a 1.5 sürümüyle geldi, OpenTofu'da ilk kararlı sürümden beri var; for_each ile toplu import Terraform 1.7 ve OpenTofu 1.7'de eklendi.
- tofu plan -generate-config-out=generated.tf ile yapılandırma ürettirin. Terraform belgeleri bu bayrağı hâlâ deneysel olarak işaretliyor; üretilen dosyayı inceleyip gereksiz değerleri temizleyin.
- Plan yalnızca içe aktarmayı gösterip başka değişiklik önermeyene kadar yapılandırmayı düzeltin, sonra apply edin; kritik kaynaklara prevent_destroy = true ekleyin.
Code:
# infra/imports.tf
import {
to = digitalocean_droplet.legacy
id = "123456789" # doctl çıktısındaki Droplet kimliği
}
Sık yapılan hatalar
- State'i dizüstü bilgisayarda ya da git deposunda tutmak; kilitsiz backend ile iki pipeline'ın aynı state'e yazması.
- tfvars veya HCL içine parola yazmak ve sensitive = true'nun state'i koruduğunu sanmak.
- Sunucu yapılandırmasını remote-exec provisioner'larıyla yapmak.
- Konsoldan değişiklik yapıp koda işlememek; bir gün apply o değişikliği sessizce geri alır.
- Bütün ortamları tek state'te toplamak; staging hatasının production'ı etkilemesi.
- Sağlayıcı ve modül sürümlerini sabitlememek, .terraform.lock.hcl'i commit etmemek.
- Ansible'da creates veya changed_when olmadan shell kullanmak; her çalıştırma "changed" der ve gerçek değişiklik gözden kaçar.
- Yeni kullanıcının anahtarı ve sudo yetkisi doğrulanmadan root girişini kapatmak.
- OIDC güven politikasında joker karakterli sub koşulu bırakmak.
- HCL veya Pulumi'de "code golf": iç içe ifadelerle kısaltılmış, kimsenin okuyamadığı kod.
Sık sorulan sorular
Terraform mı OpenTofu mu?Açık kaynak lisans, state şifreleme veya topluluk yönetişimi önemliyse OpenTofu; HCP Terraform, Sentinel ya da yalnızca Terraform'da olan özellikleri kullanıyorsanız Terraform. Ortak HCL çekirdeği sayesinde karar geri dönülmez değil, ama araca özgü her özellik geçişi pahalılaştırır.
Terraform ile Ansible birbirinin alternatifi mi?
Hayır, birbirini tamamlar. Terraform/OpenTofu kaynağı oluşturup state'te izler, Ansible sunucunun içini yapılandırır. Ansible da sunucu açabilir, ama state tutmadığı için silme ve sapma takibi size kalır.
Pulumi, Terraform'dan daha mı iyi?
Farklı. Ekip altyapıyı uygulama diliyle yazıp birim testleriyle korumak istiyorsa Pulumi verimlidir; bildirimsel (declarative), sınırları belli bir dil isteyen ekiplerde HCL daha az sürpriz üretir.
BUSL şirket içi kullanımımızı etkiler mi?
Lisans metnine göre Terraform'u kuruluş içinde barındırmak veya kullanmak rakip ürün (competitive offering) sayılmıyor. Kısıt, Terraform'u barındıran ya da gömen, üçüncü taraflara ücretli sunulan ve IBM'in ücretli sürümleriyle önemli ölçüde örtüşen ürünleri hedefliyor.
State dosyasını git'e koyabilir miyim?
Koymayın. State gizli bilgi içerir, git'te kilitlenemez ve birleştirme çakışmalarında bozulur.
Kubernetes kullanıyorsak Ansible'a gerek var mı?
Küme içindeki iş yükleri için çoğunlukla hayır; orada Helm, GitOps araçları ya da Crossplane devreye girer. Düğümler, ağ cihazları ve küme dışındaki sunucular için Ansible hâlâ işe yarar.
Sonuç ve sonraki adımlar
IaC seçimi katman başına bir karardır: kaynakları oluşturan araç, sunucunun içini yapılandıran araç ve ikisini sıraya koyan pipeline. Terraform 1.6.0'dan beri BUSL 1.1 altında, OpenTofu ise MPL 2.0 ile CNCF çatısında ilerliyor; araçlar ayrıştıkça kullandığınız her özellik bir bağlılık kararına dönüşüyor. Önerdiğimiz sıra:- Her katman için tek araç seçin: provisioning için OpenTofu veya Terraform (yazılımcı ağırlıklı ekipte Pulumi), sunucu içi için Ansible.
- İlk günden uzak state, kilit ve sürümleme kurun; OpenTofu'da state şifrelemeyi değerlendirin.
- Örneği bir test hesabında çalıştırın ve ikinci Ansible çalıştırmasında changed=0 görün.
- Gece çalışan sapma işini ve PR'daki plan yorumunu ekleyin, CI kimlik bilgilerini OIDC'ye taşıyın.
- Mevcut sunucuları import ve --check --diff ile adım adım koda alın.
"Which IaC tool should we use?" sounds like one question, but it hides three decisions: which tool creates the cloud resources (provisioning), which tool configures what runs inside the servers (configuration management), and who runs those steps in which order (orchestration). This guide compares the tools along that split, summarises where things stand after Terraform's license change with dates checked against the primary sources, and builds one pipeline end to end: OpenTofu (or Terraform) creates a server, a firewall and a DNS record, then Ansible adds a user and SSH key and installs packages and Nginx. After that come state, drift detection, testing, OIDC, moving to OpenTofu and bringing existing servers under code. Versions were checked in October 2026, when the current releases were Terraform 1.17 and OpenTofu 1.13.
Three layers: provisioning, configuration and orchestration
- Provisioning: creating servers, networks, firewalls, DNS records, buckets or managed databases through a provider's API. You describe the desired state; the tool compares it with reality, produces a plan and applies only the difference. Terraform, OpenTofu, Pulumi, CloudFormation/CDK, Bicep and Crossplane live here.
- Configuration management: everything inside the machine, such as users, keys, packages, files and services. Tasks are idempotent, so a second run changes nothing when nothing needs changing. Ansible, Salt, Chef and Puppet live here.
- Orchestration: the order of the steps (plan, approval, apply, configure, verify) and the gates between them. This usually belongs to the CI/CD pipeline; teams with many state files add Terragrunt, Atlantis or platforms such as Spacelift, env0 and Scalr. Kubernetes "container orchestration" is a separate decision, covered in our Docker, Podman and Kubernetes guide.
Decision table: which tool for which job
| Tool | Layer | Language | State model | License | Best fit |
|---|---|---|---|---|---|
| Terraform | Provisioning | HCL | State file | BUSL 1.1 (1.6.0 and later) | Multi-provider estates, teams on HCP Terraform |
| OpenTofu | Provisioning | HCL | State file, optional client-side encryption | MPL 2.0 | Open source license requirement, migration from Terraform |
| Pulumi | Provisioning | TypeScript, Python, Go, C#, Java, YAML | Pulumi Cloud or S3, Blob, GCS, PostgreSQL | Apache 2.0 (CLI, SDKs) | Developer teams who want infrastructure in their app language, with tests |
| CloudFormation / CDK | Provisioning (AWS) | YAML/JSON; CDK in TypeScript, Python and others | AWS keeps the stack | AWS service; CDK Apache 2.0 | AWS-only shops |
| Bicep | Provisioning (Azure) | Bicep, compiled to ARM JSON | No state file | MIT | Azure-only shops |
| Crossplane | Provisioning (via Kubernetes) | YAML (CRDs) | Continuous reconciliation | Apache 2.0 | Kubernetes-centric platform teams |
| Ansible | Configuration | YAML | No state, agentless (SSH) | GPLv3 | The default; small to mid-size fleets |
| Salt | Configuration | YAML + Jinja | Master/minion, event bus | Apache 2.0 | Large fleets, event-driven automation |
| Chef | Configuration | Ruby DSL | Server + agent (pull) | Code Apache 2.0; packages under EULA | Existing Chef estates |
| Puppet | Configuration | Puppet DSL | Server + agent (pull) | Code Apache 2.0; new packages under EULA | Existing, compliance-heavy estates |
- Servers at one vendor, DNS at Cloudflare, backups in S3, all in one plan: Terraform or OpenTofu.
- A team that wants loops and unit tests in a language it already knows: Pulumi. The price is that infrastructure code then needs the same dependency and review discipline as application code.
- AWS only or Azure only, and no wish to run state: CloudFormation/CDK or Bicep. If a second cloud is likely, starting with HCL makes the later move cheaper.
- A platform already on Kubernetes that wants manual changes reverted automatically: Crossplane, which reached CNCF graduated status in October 2025.
- Inside the servers, Ansible is the default; Salt for thousands of nodes and event-driven reactions. Chef and Puppet are mostly kept by teams that already run them.
Licensing and governance: Terraform, OpenTofu and IBM
- 10 August 2023: HashiCorp announced that future releases of its products would move from the Mozilla Public License 2.0 to the Business Source License 1.1 (BUSL, also written BSL); APIs, SDKs and almost all libraries stayed on MPL 2.0. The LICENSE file in the Terraform repository defines the licensed work as "Terraform Version 1.6.0 or later"; 1.5.x and earlier remain MPL 2.0.
- What BUSL restricts: the Additional Use Grant allows production use. What it forbids is offering Terraform to third parties on a hosted or embedded basis in order to compete with the licensor's (today IBM's) paid versions. The license defines a competitive offering as a paid product that significantly overlaps with those versions, and hosting or using Terraform for internal purposes is explicitly not one. Each version converts to MPL 2.0 four years after it is published. The people affected are mainly vendors building commercial services on Terraform and organisations that accept only OSI-approved licenses.
- 20 September 2023: the Linux Foundation announced OpenTofu as an open source successor to MPL-licensed Terraform. OpenTofu is distributed under MPL 2.0; its first stable release, 1.6.0, shipped in January 2024, and the project was accepted into the CNCF at Sandbox level on 23 April 2025.
- 27 February 2025: IBM completed the acquisition of HashiCorp it had announced in 2024; the Terraform LICENSE file now names IBM as licensor. Terraform Cloud has been called HCP Terraform since 22 April 2024.
Ownership moved on the configuration side too. Ansible is GPLv3 and is run by Red Hat, which IBM owns, so Terraform and Ansible now sit under the same company. Salt is Apache 2.0 and sits with Broadcom. Chef's source code is Apache 2.0, but packages distributed by Progress require accepting the Chef EULA, and the free license covers non-production use only. Perforce, which owns Puppet, announced on 7 November 2024 that from 2025 new binaries would ship from a private repository under an EULA that is free for development use up to 25 nodes; the source stayed Apache 2.0 and the community answered with the OpenVox fork. Before you choose, settle three things: who distributes the binary you run and under which license, whether your use is internal or part of a service you sell, and whether your organisation allows only open source licenses. This is not legal advice; if a commercial product is involved, read the license with your lawyers.
Worked example: server, firewall and DNS with OpenTofu, then the inside with Ansible
The example uses the DigitalOcean provider because it covers a server, a firewall and a DNS record in one provider and few lines; it assumes the domain's DNS is hosted there. All values are placeholders (example.com and addresses reserved for documentation, such as 198.51.100.10). The code runs with both tofu and terraform; put admin_cidr and ssh_public_key in terraform.tfvars, since neither is a secret.Step 1: Describe the infrastructure (HCL)
Code:
# infra/main.tf
terraform {
required_version = ">= 1.6"
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.0"
}
}
}
# No token in code: the provider reads the DIGITALOCEAN_TOKEN environment variable.
provider "digitalocean" {}
variable "admin_cidr" {
type = string # e.g. 198.51.100.10/32
validation {
condition = can(cidrhost(var.admin_cidr, 0))
error_message = "admin_cidr must be a valid CIDR."
}
}
variable "ssh_public_key" {
type = string
}
variable "domain" {
type = string
default = "example.com"
}
resource "digitalocean_ssh_key" "admin" {
name = "admin"
public_key = var.ssh_public_key
}
resource "digitalocean_droplet" "web" {
name = "web-01"
region = "fra1"
size = "s-1vcpu-2gb"
image = "ubuntu-24-04-x64"
ssh_keys = [digitalocean_ssh_key.admin.fingerprint]
tags = ["web"]
}
resource "digitalocean_firewall" "web" {
name = "web-fw"
droplet_ids = [digitalocean_droplet.web.id]
inbound_rule {
protocol = "tcp"
port_range = "22"
source_addresses = [var.admin_cidr]
}
inbound_rule {
protocol = "tcp"
port_range = "80"
source_addresses = ["0.0.0.0/0", "::/0"]
}
inbound_rule {
protocol = "tcp"
port_range = "443"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# Traffic without a matching rule is blocked, so write outbound rules too.
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "udp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "icmp"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
}
resource "digitalocean_record" "www" {
domain = var.domain
type = "A"
name = "www"
value = digitalocean_droplet.web.ipv4_address
ttl = 300
}
output "web_ipv4" {
value = digitalocean_droplet.web.ipv4_address
}
- DigitalOcean's documentation says cloud firewalls block all traffic that is not expressly permitted by a rule. Leave out the outbound rules and apt and DNS resolution will hang.
- According to the provider docs, changing ssh_keys or user_data later destroys and recreates the server. Set the first key here and leave later key changes to Ansible.
- The rules could be shortened with dynamic blocks; they are written out on purpose. Explicit, verbose, "boring" IaC is easier to maintain six months later than clever code.
Step 2: Plan, apply and generate the inventory
Bash:
cd infra
export DIGITALOCEAN_TOKEN="..." # comes from the secret store in CI
tofu init
tofu plan -out=web.tfplan # the plan you review is saved
tofu apply web.tfplan # and exactly that plan is applied
IP="$(tofu output -raw web_ipv4)"
for i in $(seq 30); do # wait up to ~2.5 min for sshd
ssh-keyscan "$IP" 2>/dev/null | grep -q . && break
sleep 5
done
ssh-keyscan -H "$IP" >> ~/.ssh/known_hosts # check the fingerprint (see below)
printf '[web]\n%s ansible_user=root\n' "$IP" > ../ansible/inventory.ini
cd ../ansible
ansible-galaxy collection install ansible.posix
ansible-playbook -i inventory.ini site.yml
Step 3: Configure the server with Ansible
YAML:
# ansible/site.yml
- name: Configure the web server
hosts: web
become: true
gather_facts: false
vars:
deploy_user: deploy
site_domain: www.example.com
tasks:
- name: Wait until SSH answers
ansible.builtin.wait_for_connection:
timeout: 300
- name: Let cloud-init finish (avoids apt lock clashes)
ansible.builtin.command: cloud-init status --wait
register: ci
changed_when: false
failed_when: ci.rc not in [0, 2] # 2 = finished with recoverable errors
- name: Install packages
ansible.builtin.apt:
name: [nginx, unattended-upgrades]
state: present
update_cache: true
cache_valid_time: 3600
- name: Deploy user
ansible.builtin.user:
name: "{{ deploy_user }}"
groups: sudo
append: true
shell: /bin/bash
- name: SSH key for the deploy user
ansible.posix.authorized_key:
user: "{{ deploy_user }}"
key: "{{ lookup('ansible.builtin.file', 'files/deploy.pub') }}"
exclusive: true
- name: Passwordless sudo (validated with visudo)
ansible.builtin.copy:
dest: "/etc/sudoers.d/90-{{ deploy_user }}"
content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
mode: "0440"
validate: /usr/sbin/visudo -csf %s
- name: SSH hardening (00- prefix, first value wins)
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/00-hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: reload ssh
- name: Nginx site configuration
ansible.builtin.template:
src: templates/site.conf.j2
dest: /etc/nginx/sites-available/site.conf
mode: "0644"
notify: reload nginx
- name: Enable the site
ansible.builtin.file:
src: /etc/nginx/sites-available/site.conf
dest: /etc/nginx/sites-enabled/site.conf
state: link
notify: reload nginx
handlers:
- name: Reload the SSH service
ansible.builtin.service:
name: ssh
state: reloaded
listen: reload ssh
- name: Test the Nginx configuration
ansible.builtin.command: nginx -t
changed_when: false
listen: reload nginx
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: reload nginx
Code:
# ansible/templates/site.conf.j2 (TLS is out of scope here)
server {
listen 80;
listen [::]:80;
server_name {{ site_domain }};
root /var/www/html;
index index.html index.nginx-debian.html;
}
- The playbook expects two files next to it: files/deploy.pub (the deploy user's public key) and templates/site.conf.j2 (shown above).
- On a fresh server cloud-init may still be running apt in the background, which is where most "Could not get lock" errors come from. cloud-init status --wait blocks until it is done; it returns 2 when cloud-init finished with recoverable errors, so the play accepts 0 and 2, but a 2 is worth a look in /var/log/cloud-init.log.
- sshd uses the first value it reads for each keyword and processes Include files in lexical order. Some cloud images drop a file containing PasswordAuthentication (for example 50-cloud-init.conf) into sshd_config.d, which is why the hardening file name starts with 00-.
- Root login is disabled at the end of this run. Before the next run, set ansible_user=deploy in the inventory and test the login from a second terminal without closing the session you already have.
- On Debian and Ubuntu the SSH service is called ssh. On the RHEL family it is sshd, the admin group is wheel rather than sudo, you would use the dnf module instead of apt, and Nginx reads /etc/nginx/conf.d rather than sites-enabled.
- The second run should report changed=0. If it does not, a task is not idempotent; give command and shell tasks a creates or changed_when.
The same infrastructure in Pulumi (Python)
In Pulumi resources are Python objects, and repeated rules come from an ordinary list comprehension. Install with pip install pulumi pulumi-digitalocean; the provider reads the same DIGITALOCEAN_TOKEN variable or the digitalocean:token setting.
Python:
# __main__.py
import pulumi
import pulumi_digitalocean as do
cfg = pulumi.Config()
admin_cidr = cfg.require("adminCidr")
domain = cfg.get("domain") or "example.com"
anywhere = ["0.0.0.0/0", "::/0"]
key = do.SshKey("admin", public_key=cfg.require("sshPublicKey"))
web = do.Droplet(
"web",
image="ubuntu-24-04-x64",
region="fra1",
size="s-1vcpu-2gb",
ssh_keys=[key.fingerprint],
tags=["web"],
)
inbound = [{"protocol": "tcp", "port_range": "22", "source_addresses": [admin_cidr]}]
inbound += [
{"protocol": "tcp", "port_range": port, "source_addresses": anywhere}
for port in ("80", "443")
]
outbound = [
{"protocol": proto, "port_range": "1-65535", "destination_addresses": anywhere}
for proto in ("tcp", "udp")
]
outbound.append({"protocol": "icmp", "destination_addresses": anywhere})
do.Firewall(
"web",
droplet_ids=[web.id.apply(int)], # the ID is a string, the field wants ints
inbound_rules=inbound,
outbound_rules=outbound,
)
do.DnsRecord("www", domain=domain, type="A", name="www",
value=web.ipv4_address, ttl=300)
pulumi.export("web_ipv4", web.ipv4_address)
Equivalents on other providers
| Provider | Server | Firewall | DNS record |
|---|---|---|---|
| DigitalOcean | digitalocean_droplet | digitalocean_firewall | digitalocean_record |
| Hetzner Cloud | hcloud_server | hcloud_firewall | hcloud_zone_rrset |
| AWS | aws_instance | aws_security_group | aws_route53_record |
| Azure | azurerm_linux_virtual_machine | azurerm_network_security_group | azurerm_dns_a_record |
| Google Cloud | google_compute_instance | google_compute_firewall | google_dns_record_set |
| Cloudflare (DNS only) | - | - | cloudflare_dns_record (v5; cloudflare_record in v4) |
State: remote backends, locking and encryption
State is the JSON file that maps every resource in your code to the real object (IDs, IP addresses, attributes). Lose it and the tool no longer recognises your resources; let two people write it at once and it corrupts; and it contains secrets. From day one you need a remote backend, locking, versioning and restricted access.- Where: S3 and S3-compatible stores, GCS, Azure Blob, PostgreSQL, GitLab's HTTP backend or HCP Terraform. Pulumi uses Pulumi Cloud or pulumi login with s3://, azblob://, gs:// or postgres://; these DIY backends enable a basic file-based lock by default.
- Locking: Terraform 1.10 added S3-native locking to the S3 backend (use_lockfile); 1.11 made it generally available and deprecated the DynamoDB arguments. The lock is written as a .tflock object next to the state key, and the role needs s3:GetObject, s3
utObject and s3
eleteObject on it. OpenTofu added the same feature in 1.10; its docs say the lock is taken with a conditional write (If-None-Match) and that there are no plans to deprecate DynamoDB locking. When migrating, run both locks for a while, then remove dynamodb_table and run init -reconfigure. On S3-compatible stores such as MinIO or Ceph, test conditional-write support first. - Versioning and stuck locks: turn on object versioning in the bucket; it is the fastest way back from a broken state. An interrupted job can leave a lock behind; use force-unlock only after you are sure that job has stopped.
Code:
terraform {
backend "s3" {
bucket = "acme-iac-state"
key = "prod/web.tfstate"
region = "eu-central-1"
encrypt = true
use_lockfile = true # Terraform >= 1.10, OpenTofu >= 1.10
}
}
- Encryption at rest in the backend, narrow access and audit logging.
- Ephemeral values and write-only arguments (Terraform 1.10/1.11, OpenTofu 1.11): when the provider supports them, the value never reaches state or the plan.
- Client-side encryption, introduced in OpenTofu 1.7: state and plan files are encrypted with AES-GCM before they reach the backend, with keys from a PBKDF2 passphrase, AWS KMS, GCP KMS or OpenBao; current docs also list Azure Key Vault and an external program.
- In Pulumi, values set with --secret and outputs derived from them are encrypted in state with the chosen secrets provider (passphrase, awskms, azurekeyvault, gcpkms, hashivault or Pulumi Cloud).
Code:
variable "state_passphrase" {
type = string
sensitive = true # at least 16 characters, e.g. from TF_VAR_state_passphrase
}
terraform {
encryption {
key_provider "pbkdf2" "main" {
passphrase = var.state_passphrase
}
method "aes_gcm" "main" {
keys = key_provider.pbkdf2.main
}
state {
method = method.aes_gcm.main
enforced = true
}
plan {
method = method.aes_gcm.main
enforced = true
}
}
}
Drift detection, module versioning and testing
Drift is the gap between real infrastructure and the code: quick console fixes, fields touched by other tools (autoscaling, security scripts) and changed provider defaults. The simplest detector is a nightly plan against the main branch. With -detailed-exitcode, plan exits 0 for no changes, 1 for an error and 2 when there is a diff; if the main branch is already applied, a 2 means someone changed the infrastructure outside the code.
YAML:
# .github/workflows/drift.yml
name: drift
on:
schedule:
- cron: "17 3 * * *" # nightly at 03:17 UTC
workflow_dispatch: {}
permissions:
contents: read
id-token: write # required for the OIDC token
jobs:
drift:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v7
- uses: opentofu/setup-opentofu@v2
with:
tofu_wrapper: false # keep the raw exit code
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::111122223333:role/iac-readonly
aws-region: eu-central-1
- name: Plan and drift check
env:
DIGITALOCEAN_TOKEN: ${{ secrets.DO_TOKEN_READONLY }}
run: |
tofu init -input=false
code=0
tofu plan -input=false -lock-timeout=5m -detailed-exitcode -no-color > plan.txt || code=$?
cat plan.txt
if [ "$code" -eq 2 ]; then echo "::error::Drift: infrastructure differs from code"; fi
exit "$code"
Module versioning
- Release modules with semver tags; a breaking change bumps the major version and goes into the CHANGELOG.
- Environments pin modules: ?ref=v1.4.0 for git sources, version = "~> 1.4" for registry modules.
- Constrain provider versions in required_providers and commit .terraform.lock.hcl; without it, two machines can plan with different provider versions.
- Let Renovate or Dependabot open update PRs and review each with its plan output.
- Split state by environment and component (prod/network, prod/web); one giant state slows every plan and widens the blast radius of a mistake.
Testing layers
- Static: tofu fmt -check, tofu validate and tflint; Checkov or Trivy for security scanning.
- Unit: terraform test, generally available since Terraform 1.6, and tofu test run the run blocks in .tftest.hcl files; with mock_provider (Terraform 1.7, OpenTofu 1.8) no cloud account is needed.
- Integration: Terratest (Go) creates real resources, checks them and destroys them; run it nightly or before releases because of time and cost.
- Ansible: ansible-lint, --check --diff and Molecule. Molecule's default test sequence creates an instance, converges the role, runs it again to confirm nothing changes (idempotence), runs the verifier and destroys the instance.
Code:
# infra/tests/web.tftest.hcl (mock provider: no token needed)
mock_provider "digitalocean" {}
variables {
admin_cidr = "198.51.100.10/32"
ssh_public_key = "ssh-ed25519 AAAAexample test"
}
run "ssh_only_from_admin_cidr" {
command = plan
assert {
condition = alltrue([
for r in digitalocean_firewall.web.inbound_rule :
r.port_range != "22" || toset(r.source_addresses) == toset([var.admin_cidr])
])
error_message = "22/tcp must be open to admin_cidr only."
}
}
CI/CD: short-lived credentials with OIDC, and policy as code
A long-lived cloud key stored as a CI secret stays valid for months after it leaks. With OIDC, every job gets a signed identity token from the CI system, the cloud provider checks it against the role's trust conditions and returns short-lived credentials. In GitHub Actions the job needs id-token: write under permissions; in AWS the identity provider URL is https://token.actions.githubusercontent.com and the audience is sts.amazonaws.com. GitLab CI does the same with the id_tokens keyword, and Azure and Google Cloud support workload identity federation.
Code:
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/infra:environment:production"
}
}
- On a pull request: fmt, validate, test, and a plan with the read-only role, posted as a PR comment.
- After merge: plan the same commit with -out, have the production environment's reviewers approve (GitHub environments with required reviewers), and apply only that plan file.
- After apply: run Ansible from a machine that admin_cidr allows (a self-hosted runner or a bastion, rather than opening port 22 to every hosted runner), then a smoke test such as expecting HTTP 200 from curl.
- Plan files can contain secrets too; keep artifact retention short.
Policy as code lets rules check a plan before a human does. On the open source side, Open Policy Agent (OPA) with the Rego language is the common choice: Conftest tests the output of tofu show -json against rules such as "22/tcp must not be open to 0.0.0.0/0" or "every resource needs an owner tag". HCP Terraform and Terraform Enterprise support HashiCorp's Sentinel and OPA. HashiCorp's Terraform policy documentation still carries a beta notice for the new HCL-based policy framework, while the Terraform 1.17 release notes call Terraform Policy generally available and drop the experimental requirement for the -policies flag of plan, apply and query; check the current status before relying on it. OPA and Conftest are tool-neutral and work with both Terraform and OpenTofu plans.
Bash:
tofu show -json web.tfplan > plan.json
conftest test plan.json --policy policy/
Moving from Terraform to OpenTofu
A move makes sense when your organisation requires an open source license, when you need state encryption, or when you prefer a community-governed tool. If you lean on HCP Terraform for remote runs, Sentinel or the private registry, the move is more than renaming a binary: you need a replacement for that platform. The steps in OpenTofu's migration guide:- Run a Terraform plan and confirm "No changes"; back up the state, commit everything and work on a separate branch.
- Install OpenTofu and check tofu --version.
- Run tofu init: providers come from the OpenTofu registry and the backend is initialised. If .terraform.lock.hcl changes, review and commit the diff.
- Run tofu plan: expect "No changes" or the same plan Terraform would produce; do not apply if anything unexpected appears.
- Run tofu apply; even without infrastructure changes it may update the state format.
- Try a small, non-critical change, such as a tag, through a full plan and apply.
- To roll back, restore the state from backup if it changed, then confirm with terraform init and terraform plan that there is no diff.
Bringing existing servers under IaC (import)
Hand-built resources can be adopted without rebuilding them:- List resources and their IDs with the provider's CLI (for example doctl compute droplet list). terraform query, added in Terraform 1.14, can discover existing resources and generate import configuration for providers that support list resources.
- Write an import block for each resource. Terraform added the block in 1.5 and OpenTofu has had it since its first stable release; for_each on import blocks arrived in Terraform 1.7 and OpenTofu 1.7.
- Generate configuration with tofu plan -generate-config-out=generated.tf. Terraform's documentation still marks this flag experimental, so review the generated file and strip values you do not need.
- Edit until the plan shows only the import and no other changes, then apply; add prevent_destroy = true to critical resources.
Code:
# infra/imports.tf
import {
to = digitalocean_droplet.legacy
id = "123456789" # Droplet ID from the doctl output
}
Common mistakes
- Keeping state on a laptop or in git, or letting two pipelines write the same state without a lock.
- Putting passwords in tfvars or HCL and assuming sensitive = true protects the state.
- Configuring servers with remote-exec provisioners.
- Fixing things in the console and never putting the change into code; one day an apply silently reverts it.
- One state for every environment, so a staging mistake can reach production.
- Unpinned provider and module versions, and an uncommitted .terraform.lock.hcl.
- Ansible shell tasks without creates or changed_when; every run reports "changed" and real changes get lost in the noise.
- Disabling root login before the new user's key and sudo rights are confirmed.
- A wildcard sub condition in the OIDC trust policy.
- "Code golf" in HCL or Pulumi: nested expressions nobody can read.
FAQ
Terraform or OpenTofu?OpenTofu if an open source license, state encryption or community governance matters to you; Terraform if you use HCP Terraform, Sentinel or features only Terraform has. The shared HCL core keeps the decision reversible, but every tool-specific feature makes a later move more expensive.
Are Terraform and Ansible alternatives?
No, they complement each other. Terraform/OpenTofu creates resources and tracks them in state; Ansible configures the inside of the server. Ansible can create servers, but without state, deletions and drift tracking are left to you.
Is Pulumi better than Terraform?
It is different. Pulumi pays off when the team wants infrastructure in its application language, protected by unit tests; HCL produces fewer surprises for teams that want a declarative language with clear limits.
Does the BUSL affect our internal use?
According to the license text, hosting or using Terraform for internal purposes is not a competitive offering. The restriction targets paid products, offered to third parties, that host or embed Terraform and significantly overlap with IBM's paid versions.
Can I keep the state file in git?
No. State contains secrets, git cannot lock it, and merge conflicts corrupt it.
If we run Kubernetes, do we still need Ansible?
Usually not for workloads inside the cluster, where Helm, GitOps tools or Crossplane take over. For the nodes underneath, network devices and servers outside the cluster, Ansible is still useful.
Conclusion and next steps
Choosing IaC is a decision per layer: the tool that creates resources, the tool that configures the inside of servers, and the pipeline that puts them in order. Terraform has been under BUSL 1.1 since 1.6.0 and OpenTofu continues under MPL 2.0 within the CNCF; as the two drift apart, every feature you adopt becomes a commitment. The order we recommend:- Pick one tool per layer: OpenTofu or Terraform for provisioning (Pulumi for developer-heavy teams) and Ansible for the inside of servers.
- Set up remote state, locking and versioning from day one; on OpenTofu, consider state encryption.
- Run the example in a test account and confirm the second Ansible run reports changed=0.
- Add the nightly drift job and PR plan comments, and move CI credentials to OIDC.
- Bring existing servers in step by step with import and --check --diff.
Last edited: