Skip to content
ENTERPRISE Enterprise technology · Engineered for your business Enterprise Software · Managed Servers · Business E-mail · LiteSpeed + CloudLinux · Imunify360 Security · cPanel Control · Network profile up to 3 Gbps on eligible plans
AIOR

Terraform, Pulumi, Ansible: choosing infrastructure-as-code without the risk

Aior

Administrator

Staff member
Joined
Apr 2, 2023
Messages
929
Reaction score
2
Points
18
Age
41
Location
Turkey
Website
aior.com
Thread owner

"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.
Katmanlar birbirinin yerine geçebilir gibi görünür: Ansible'ın bulut modülleri sunucu açabilir, Terraform'un provisioner'ları sunucuda komut çalıştırabilir. Fark, neyin takip edildiğindedir. Terraform, OpenTofu ve Pulumi oluşturdukları her kaynağı state'te tutar; kaynağı koddan sildiğinizde sonraki plan onu silmeyi önerir. Ansible'ın state'i yoktur: playbook'tan çıkardığınız görevin oluşturduğu kaynak yerinde kalır, silmek için state: absent yazmanız gerekir. HashiCorp'un kendi belgeleri de provisioner'ı son çare olarak görüyor ve önce makine imajı, yapılandırma yönetimi gibi alternatiflerin denenmesini öneriyor. Kural: kaynakları provisioning aracı, sunucunun içini yapılandırma aracı yönetsin; köprüyü cloud-init ya da üretilen envanter dosyası kursun. Hazır imaj ve konteyner kullanan ekiplerde yapılandırma katmanı küçülür, ama sunucu ve ağ cihazı yöneten çoğu ekip için Ansible gibi bir araç gerekli kalır.

Karar tablosu: hangi araç, hangi iş​

AraçKatmanDilDurum modeliLisansEn iyi uyduğu yer
TerraformProvisioningHCLState dosyasıBUSL 1.1 (1.6.0 ve sonrası)Çok sağlayıcılı ortamlar, HCP Terraform kullananlar
OpenTofuProvisioningHCLState dosyası, isteğe bağlı istemci tarafı şifrelemeMPL 2.0Açık kaynak lisans şartı, Terraform'dan geçiş
PulumiProvisioningTypeScript, Python, Go, C#, Java, YAMLPulumi Cloud ya da S3, Blob, GCS, PostgreSQLApache 2.0 (CLI, SDK)Altyapıyı uygulama diliyle ve testli yazmak isteyen ekipler
CloudFormation / CDKProvisioning (AWS)YAML/JSON; CDK ile TypeScript, Python vb.Stack'i AWS tutarAWS hizmeti; CDK Apache 2.0Yalnızca AWS kullananlar
BicepProvisioning (Azure)Bicep, ARM JSON'a derlenirState dosyası yokMITYalnızca Azure kullananlar
CrossplaneProvisioning (Kubernetes ile)YAML (CRD)Sürekli uzlaştırma (reconcile)Apache 2.0Kubernetes merkezli platform ekipleri
AnsibleYapılandırmaYAMLState yok, ajansız (SSH)GPLv3Varsayılan seçim, küçük ve orta filolar
SaltYapılandırmaYAML + JinjaMaster/minion, olay veri yoluApache 2.0Büyük filolar, olay güdümlü otomasyon
ChefYapılandırmaRuby DSLSunucu + ajan (pull)Kod Apache 2.0; paketler EULA'lıMevcut Chef kurulumları
PuppetYapılandırmaPuppet DSLSunucu + ajan (pull)Kod Apache 2.0; yeni paketler EULA'lıMevcut, uyumluluk ağırlıklı kurulumlar
Kısa karar kuralları:
  • 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.
Çatallanmadan (fork) sonra iki araç ayrışıyor. OpenTofu'ya istemci tarafı state şifreleme (1.7), modül kaynağında ve backend'de değişken kullanımı ile .tofu dosyaları (1.8), provider bloğunda for_each ve -exclude (1.9), OCI registry desteği (1.10) geldi; Terraform modül kaynağındaki değişken desteğini 1.15'te ekledi. Terraform'da ise terraform query, list kaynakları ve actions blokları (1.14) ile HCP Terraform'la çalışan Stacks var. Ephemeral değerler ve write-only argümanlar önce Terraform'a (1.10, 1.11), sonra OpenTofu'ya (1.11) geldi. HCL'in ortak çekirdeği ikisinde de çalışır, ama yalnızca birinde olan bir özelliği kullandığınız anda o araca bağlanırsınız.

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
Kaydedilmiş plan apply sırasında yeniden hesaplanmaz; incelenen ile uygulanan değişiklik aynı olur. Sağlayıcı sunucuyu sshd yanıt vermeden biraz önce "active" gösterebilir; kısa bekleme döngüsü bu yüzden var. ssh-keyscan çıktısını known_hosts'a eklemek, sunucunun ilk bağlantıda gösterdiği anahtara körü körüne güvenmek demektir; üretimde bu anahtarı sağlayıcının web konsolunda gördüğünüz parmak iziyle (ör. ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub) ya da sağlayıcı sunuyorsa açılış günlüğüyle karşılaştırın. Filo büyüdükçe statik envanter yerine dinamik envanter eklentisine geçin.

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)
Değerler pulumi config set adminCidr 198.51.100.10/32 ile (sshPublicKey de aynı şekilde), token ise pulumi config set --secret digitalocean:token ile girilir; değeri komutta yazmazsanız Pulumi sorar. Ardından pulumi preview ve pulumi up gelir. Pratik farklar: name özelliğini vermediğiniz kaynaklarda Pulumi buluttaki adı mantıksal addan ve rastgele bir sonekten üretir (auto-naming), yapılandırma yığın (stack) bazında tutulur, --secret değerler state'te şifreli saklanır ve test için pytest gibi alışık olduğunuz araçlar kullanılabilir. Bedeli, Python'un bütün esnekliğinin altyapı koduna da açılmasıdır; "zekice değil, açık yaz" kuralı burada daha da önemli.

Diğer sağlayıcılarda karşılıklar​

SağlayıcıSunucuGüvenlik duvarıDNS kaydı
DigitalOceandigitalocean_dropletdigitalocean_firewalldigitalocean_record
Hetzner Cloudhcloud_serverhcloud_firewallhcloud_zone_rrset
AWSaws_instanceaws_security_groupaws_route53_record
Azureazurerm_linux_virtual_machineazurerm_network_security_groupazurerm_dns_a_record
Google Cloudgoogle_compute_instancegoogle_compute_firewallgoogle_dns_record_set
Cloudflare (yalnız DNS)--cloudflare_dns_record (v5; v4'te cloudflare_record)
Sağlayıcınızın Terraform/OpenTofu provider'ı yoksa (bazı yerel VPS firmalarında olduğu gibi) sunucuyu panelden açıp yalnızca Ansible kısmını kullanabilirsiniz.

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:PutObject ve s3:DeleteObject 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
  }
}
State içindeki gizli bilgiler: sensitive = true yalnızca ekran çıktısını gizler; Terraform belgeleri bu değerlerin state ve plan dosyalarında saklandığını, yerel state'in düz metin olduğunu yazıyor. "Secret'ları apply sırasında secret manager'dan çekin" önerisi de tek başına yetmez, çünkü data source ile okunan değer state'e yazılır. Seçenekler:
  • 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
    }
  }
}
Şifreleme bloğunda değişken kullanmak OpenTofu 1.8 ve sonrasını gerektirir; aynı ayarlar TF_ENCRYPTION ortam değişkeniyle de verilebilir. Uyarılar: anahtarı kaybederseniz state'i de kaybedersiniz; şifreleme başladıktan sonra key_provider ve method adlarını değiştirmeyin. Şifresiz mevcut bir state'i şifrelerken ilk aşamada enforced satırını koymayın, çünkü enforced açıkken OpenTofu zincirin herhangi bir yerindeki unencrypted yöntemini reddeder. Önce method "unencrypted" "migrate" {} tanımlayın, state bloğuna bu yöntemi gösteren bir fallback bloğu ekleyin ve bir kez tofu apply çalıştırın; ardından fallback'i kaldırıp enforced'u açın. Terraform şifreli state'i okuyamaz; geri dönüş ihtimali varsa şifrelemeyi sona bırakın.

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"
Örnekte state S3'te duruyor ve AWS rolü OIDC ile alınıyor; DigitalOcean sağlayıcısı ise API token'ıyla kimlik doğruladığı için token, mümkünse salt okunur bir CI secret'ı olarak veriliyor. Buradaki "salt okunur" altyapı için geçerli: plan state kilidini yine de alır, bu yüzden rolün state'i okumanın yanında .tflock nesnesi üzerinde s3:PutObject ve s3:DeleteObject iznine ihtiyacı var (ya da sapma planını -lock=false ile çalıştırın). tofu_wrapper: false ile adım doğrudan tofu ikili dosyasını çağırır ve gerçek çıkış kodunu görür; eylemin README dosyası da çıkış kodu sorunlarında bu ayarı öneriyor. Fark çıktığında yetkisiz değişikliği apply ile geri alın, bilinçli değişikliği koda işleyin; alan başka bir sistemin sorumluluğundaysa lifecycle içinde ignore_changes ile, gerekçesini yoruma yazarak yok sayın. Gerçek değeri altyapıya dokunmadan state'e almak için -refresh-only modu var. Pulumi'de sapma kontrolünün karşılığı pulumi preview --refresh --expect-no-changes. Asıl önlem, üretim konsolunda yazma yetkisini yalnızca acil durum (break-glass) hesaplarına bırakıp her değişikliği pipeline'dan geçirmektir.

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"
  }
}
Güven koşulunu dar tutun: apply rolü yalnızca production ortamındaki işlere, salt okunur plan rolü ise yalnızca PR'lara ve ana dala (repo:acme/infra:pull_request ve repo:acme/infra:ref:refs/heads/main) güvenmeli; bir ortama (environment) bağlı işin sub değerinde dal yerine ortam adı yer alır. StringLike ile yazılmış "repo:acme/*" gibi joker karakterli bir sub koşulu, kurumdaki herhangi bir deponun veya dalın production rolünü almasına izin verir. Önerilen akış:
  1. PR açılınca fmt, validate, test ve salt okunur rolle plan; plan özeti PR'a yorum olarak düşer.
  2. 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.
  3. 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.
  4. Plan dosyaları da gizli bilgi içerebilir; CI artifact'lerinin saklama süresini kısa tutun.
Yalnızca API token'ı sunan sağlayıcılarda (birçok VPS firması böyledir) token'a mümkün olan en dar yetkiyi verin ve düzenli olarak yenileyin. Pipeline tasarımının ayrıntıları için CI/CD pipeline yazımıza bakabilirsiniz.

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:
  1. 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.
  2. OpenTofu'yu kurun, tofu --version ile doğrulayın.
  3. 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.
  4. tofu plan: "No changes" ya da Terraform'un vereceği planın aynısını görmelisiniz; beklenmedik fark varsa apply etmeyin.
  5. tofu apply: altyapı değişmese de state biçimi güncellenebilir.
  6. Kritik olmayan küçük bir değişiklikle (ör. bir etiket) döngüyü deneyin.
  7. Geri dönüş gerekirse state'i yedekten geri yükleyin, terraform init ve terraform plan ile fark olmadığını doğrulayın.
Pratik notlar: çatallanmadan sonra Terraform'a gelen bir özelliği (terraform query, actions, Stacks) kullanıyorsanız OpenTofu karşılığını önce kontrol edin; terraform_remote_state ile birbirine bağlı yapılandırmaları OpenTofu'nun ilgili rehberine göre sırayla taşıyın. CI'da opentofu/setup-opentofu kullanın; Atlantis'te --default-tf-distribution=opentofu ayarı var. İki aracı desteklemesi gereken modüllerde OpenTofu 1.8'den beri aynı adlı .tofu dosyası .tf dosyasının yerine geçer (OpenTofu foo.tofu'yu yükleyip foo.tf'yi yok sayar). State şifrelemeyi, Terraform'a dönme ihtimali kalmadığında açın.

Mevcut sunucuları koda almak (import)​

Elle kurulmuş kaynakları yeniden oluşturmadan IaC'ye almak mümkün:
  1. 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.
  2. 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.
  3. 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.
  4. 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
}
Yapılandırma tarafında import adımı yoktur, çünkü Ansible sunucunun mevcut durumunu kaydetmez. İstenen durumu bir rol olarak yazıp tek sunucuda ansible-playbook site.yml --check --diff --limit web-01 ile neyin değişeceğine bakın ve fark yalnızca bilinçli değişikliklerden oluşana kadar rolü düzeltin. Kontrol modu kusursuz bir simülasyon değildir; command ve shell görevleri bu modda atlanır. Sonra rolü önce bir sunucuya, ardından serial ile küçük gruplar hâlinde filoya uygulayın.

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:
  1. 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.
  2. İlk günden uzak state, kilit ve sürümleme kurun; OpenTofu'da state şifrelemeyi değerlendirin.
  3. Örneği bir test hesabında çalıştırın ve ikinci Ansible çalıştırmasında changed=0 görün.
  4. Gece çalışan sapma işini ve PR'daki plan yorumunu ekleyin, CI kimlik bilgilerini OIDC'ye taşıyın.
  5. Mevcut sunucuları import ve --check --diff ile adım adım koda alın.
Sağlayıcı seçimi netleşmediyse iş yüküne göre bulut seçimi yazımız iyi bir başlangıç. Bu düzeni temiz bir sunucuda denemek isterseniz AIOR'un bulut sunucuları da seçeneklerden biri; Ansible kısmı her Debian veya Ubuntu sunucuda aynı şekilde çalışır.

"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.
The layers look interchangeable: Ansible's cloud modules can create servers and Terraform's provisioners can run commands on them. The difference is what each tool tracks. Terraform, OpenTofu and Pulumi record every resource they create in state; delete a resource from the code and the next plan proposes to destroy it. Ansible keeps no state: remove a task from a playbook and whatever it created stays where it is until you write an explicit state: absent task. HashiCorp's own documentation says to exhaust alternatives such as machine images and configuration management before reaching for provisioners. The rule that works: the provisioning tool owns resources, the configuration tool owns the inside of the machine, and cloud-init or a generated inventory file bridges the two. Teams that bake images and run containers need less configuration management, but most teams that run servers and network gear still need a tool like Ansible.

Decision table: which tool for which job​

ToolLayerLanguageState modelLicenseBest fit
TerraformProvisioningHCLState fileBUSL 1.1 (1.6.0 and later)Multi-provider estates, teams on HCP Terraform
OpenTofuProvisioningHCLState file, optional client-side encryptionMPL 2.0Open source license requirement, migration from Terraform
PulumiProvisioningTypeScript, Python, Go, C#, Java, YAMLPulumi Cloud or S3, Blob, GCS, PostgreSQLApache 2.0 (CLI, SDKs)Developer teams who want infrastructure in their app language, with tests
CloudFormation / CDKProvisioning (AWS)YAML/JSON; CDK in TypeScript, Python and othersAWS keeps the stackAWS service; CDK Apache 2.0AWS-only shops
BicepProvisioning (Azure)Bicep, compiled to ARM JSONNo state fileMITAzure-only shops
CrossplaneProvisioning (via Kubernetes)YAML (CRDs)Continuous reconciliationApache 2.0Kubernetes-centric platform teams
AnsibleConfigurationYAMLNo state, agentless (SSH)GPLv3The default; small to mid-size fleets
SaltConfigurationYAML + JinjaMaster/minion, event busApache 2.0Large fleets, event-driven automation
ChefConfigurationRuby DSLServer + agent (pull)Code Apache 2.0; packages under EULAExisting Chef estates
PuppetConfigurationPuppet DSLServer + agent (pull)Code Apache 2.0; new packages under EULAExisting, compliance-heavy estates
Quick rules:
  • 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.
Since the fork the two tools have drifted apart. OpenTofu added client-side state encryption (1.7), variables in module sources and backend blocks plus .tofu files (1.8), for_each on provider blocks and the -exclude flag (1.9), and OCI registry support (1.10); Terraform added variables in module sources in 1.15. Terraform has terraform query, list resources and action blocks (1.14) and Stacks, which works with HCP Terraform. Ephemeral values and write-only arguments reached Terraform first (1.10 and 1.11) and OpenTofu later (1.11). The shared HCL core runs on both, but the moment you use a feature only one of them has, you are tied to that tool.

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
A saved plan is not recalculated at apply time, so what was reviewed is what gets applied. The provider can report the server as active a little before sshd answers, hence the short wait loop. Appending ssh-keyscan output to known_hosts trusts whatever key the server presents on first contact; in production, compare it with the fingerprint you see in the provider's web console (for example ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub) or in the boot log where the provider shows one. As the fleet grows, replace the static inventory with a dynamic inventory plugin.

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)
Set values with pulumi config set adminCidr 198.51.100.10/32 (sshPublicKey the same way) and the token with pulumi config set --secret digitalocean:token, which prompts for the value, then run pulumi preview and pulumi up. The practical differences: unless you set a resource's name property, Pulumi builds the cloud-side name from the logical name plus a random suffix (auto-naming), configuration lives per stack, values set with --secret are encrypted in state, and you test with tools you already use, such as pytest. The cost is that the full flexibility of Python is now available to infrastructure code, so "write it plainly, not cleverly" matters even more than in HCL.

Equivalents on other providers​

ProviderServerFirewallDNS record
DigitalOceandigitalocean_dropletdigitalocean_firewalldigitalocean_record
Hetzner Cloudhcloud_serverhcloud_firewallhcloud_zone_rrset
AWSaws_instanceaws_security_groupaws_route53_record
Azureazurerm_linux_virtual_machineazurerm_network_security_groupazurerm_dns_a_record
Google Cloudgoogle_compute_instancegoogle_compute_firewallgoogle_dns_record_set
Cloudflare (DNS only)--cloudflare_dns_record (v5; cloudflare_record in v4)
If your provider has no Terraform/OpenTofu provider, as with some local VPS companies, create the server in the panel and use only the Ansible part.

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:PutObject and s3:DeleteObject 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
  }
}
Secrets in state: sensitive = true only hides values from CLI output. Terraform's documentation states that such values are stored in state and plan files and that local state is plaintext. The common advice to "pull secrets from a secret manager at apply time" is not enough on its own either, because a value read through a data source is written to state. Your options:
  • 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
    }
  }
}
Referencing a variable in the encryption block needs OpenTofu 1.8 or later; the same settings can also come from the TF_ENCRYPTION environment variable. Warnings: lose the key and you lose the state; once encryption is on, do not rename key providers or methods. To encrypt an existing unencrypted state, leave enforced out at first, because OpenTofu rejects an unencrypted method anywhere in the chain while enforced is set. Declare method "unencrypted" "migrate" {}, add a fallback block pointing to it inside the state block and run tofu apply once; then remove the fallback and turn enforced on. Terraform cannot read encrypted state, so if you might go back, enable encryption last.

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"
Here the state lives in S3 and the AWS role comes through OIDC, while the DigitalOcean provider authenticates with an API token, passed as a CI secret and read-only where possible. "Read-only" refers to the infrastructure: plan still takes the state lock, so on top of reading the state the role needs s3:PutObject and s3:DeleteObject on the .tflock object (or run the drift plan with -lock=false). With tofu_wrapper: false the step calls the tofu binary directly and sees its real exit code; the action's README suggests this setting whenever exit codes look wrong. When a diff appears, revert unauthorised changes with apply, commit intentional ones to code, and if a field really belongs to another system, ignore it with ignore_changes in a lifecycle block and a comment saying why. To accept real-world values into state without touching infrastructure, use -refresh-only; Pulumi's equivalent of the drift check is pulumi preview --refresh --expect-no-changes. Prevention matters more than detection: keep write access to the production console for break-glass accounts and route every change through the pipeline.

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"
  }
}
Keep the trust condition narrow: the apply role should trust only jobs in the production environment, and the read-only plan role only pull requests and the main branch (repo:acme/infra:pull_request and repo:acme/infra:ref:refs/heads/main; a job that names an environment gets the environment in its sub instead). A wildcard such as "repo:acme/*" under StringLike lets any repository or branch in the organisation assume the production role. A flow that works:
  1. On a pull request: fmt, validate, test, and a plan with the read-only role, posted as a PR comment.
  2. 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.
  3. 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.
  4. Plan files can contain secrets too; keep artifact retention short.
Where a provider only offers API tokens, as many VPS companies do, give the token the narrowest scope available and rotate it regularly. Pipeline design and tool choice are covered in our CI/CD pipelines guide.

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:
  1. Run a Terraform plan and confirm "No changes"; back up the state, commit everything and work on a separate branch.
  2. Install OpenTofu and check tofu --version.
  3. Run tofu init: providers come from the OpenTofu registry and the backend is initialised. If .terraform.lock.hcl changes, review and commit the diff.
  4. Run tofu plan: expect "No changes" or the same plan Terraform would produce; do not apply if anything unexpected appears.
  5. Run tofu apply; even without infrastructure changes it may update the state format.
  6. Try a small, non-critical change, such as a tag, through a full plan and apply.
  7. To roll back, restore the state from backup if it changed, then confirm with terraform init and terraform plan that there is no diff.
Practical notes: if you use features Terraform added after the fork (terraform query, actions, Stacks), check for OpenTofu equivalents before you start, and migrate configurations that read each other through terraform_remote_state in the order OpenTofu's guide for interdependent configurations describes. In CI use opentofu/setup-opentofu; Atlantis has a --default-tf-distribution=opentofu setting. For modules that must support both tools, OpenTofu 1.8 and later let a .tofu file replace the .tf file with the same name (OpenTofu loads foo.tofu and ignores foo.tf). Turn on state encryption only once going back to Terraform is off the table.

Bringing existing servers under IaC (import)​

Hand-built resources can be adopted without rebuilding them:
  1. 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.
  2. 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.
  3. 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.
  4. 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
}
Configuration management has no import step, because Ansible does not record a server's current state. Write the desired state as a role, run ansible-playbook site.yml --check --diff --limit web-01 against one server, and adjust the role until the diff contains only changes you intend. Check mode is not a perfect simulation; command and shell tasks are skipped in it. Then apply the role to one server, and to the rest of the fleet in small batches with serial.

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:
  1. Pick one tool per layer: OpenTofu or Terraform for provisioning (Pulumi for developer-heavy teams) and Ansible for the inside of servers.
  2. Set up remote state, locking and versioning from day one; on OpenTofu, consider state encryption.
  3. Run the example in a test account and confirm the second Ansible run reports changed=0.
  4. Add the nightly drift job and PR plan comments, and move CI credentials to OIDC.
  5. Bring existing servers in step by step with import and --check --diff.
If your provider choice is still open, our guide to picking a cloud for the actual workload is a good place to start. To try this setup on a fresh machine, AIOR's cloud servers are one option; the Ansible part works the same on any Debian or Ubuntu server.
 
Last edited:

Forum statistics

Threads
925
Messages
932
Members
26
Latest member
Jessicawatson

Members online

No members online now.

Featured content

AIOR
AIOR TECHNOLOGY

Get a quote for all your needs

Hosting · Domain · Servers · Design · Software · Engineering · Industry Solutions

Get a quote

24/7 Support · Quick response

Back
Top