Terraform ile Cloudflare DNS Yönetimi

DNS kayıtlarını elle yönetmek, özellikle onlarca domain ve yüzlerce kayıt söz konusu olduğunda, tam anlamıyla bir kabus haline gelebilir. Bir kaydı yanlış giriyorsun, production ortamı çöküyor, hangisini değiştirdiğini hatırlamıyorsun ve gece 2’de Cloudflare panelinde kayıt avı yapıyorsun. Terraform ile Cloudflare DNS yönetimini öğrendikten sonra bu tablonun tamamen değiştiğini söyleyebilirim. Bütün DNS yapınız kod haline geliyor, versiyon kontrolüne giriyor ve değişiklikler denetlenebilir hale geliyor.

Neden Terraform ile DNS Yönetimi?

Cloudflare’in web arayüzü güzel ve kullanışlı, bunu kabul ediyorum. Ama birden fazla kişinin aynı account üzerinde çalıştığı, onlarca domain’in olduğu ve sık değişiklik yapıldığı ortamlarda manuel yönetim kaçınılmaz olarak hatalara yol açıyor.

Terraform yaklaşımının getirdikleri:

  • Versiyon kontrolü: Her DNS değişikliği Git commit’i oluyor, kim ne zaman ne değiştirdi görülüyor
  • Tekrarlanabilirlik: Aynı yapıyı farklı environment’lara (staging, production) kolayca klonlayabiliyorsun
  • Drift tespiti: terraform plan ile mevcut durum ile kodunuz arasındaki farkı görüyorsunuz
  • Ekip çalışması: Pull request süreçleriyle DNS değişiklikleri code review’dan geçiyor
  • Felaket kurtarma: DNS yapınızı sıfırdan dakikalar içinde yeniden oluşturabiliyorsunuz

Ön Gereksinimler ve Kurulum

Başlamadan önce birkaç şeyi hazır etmeniz gerekiyor. Terraform kurulu olmalı (en az 1.0 versiyonu), bir Cloudflare hesabı ve API token’ı lazım.

Cloudflare API token oluşturmak için Cloudflare dashboard’una girin, sağ üst köşedeki profil menüsünden API Tokens sayfasına gidin. “Create Token” butonuna tıklayın ve “Edit zone DNS” template’ini seçin. Zone permissions kısmında DNS:Edit yetkisini vermeniz yeterli.

Token’ı aldıktan sonra güvenli bir yerde saklayın. Bu token’ı kod içine yazmamanız kritik önemde. Environment variable veya secrets manager kullanacağız.

Proje Yapısını Oluşturalım

İyi bir Terraform projesi için düzenli bir dizin yapısı şart. Ben genellikle şu yapıyı kullanıyorum:

mkdir -p cloudflare-dns/{modules/dns-zone,environments/{staging,production}}
cd cloudflare-dns

# Temel dosyaları oluştur
touch main.tf variables.tf outputs.tf providers.tf terraform.tfvars

Dizin yapısı şu şekilde görünmeli:

cloudflare-dns/
├── main.tf
├── variables.tf
├── outputs.tf
├── providers.tf
├── terraform.tfvars
├── modules/
│   └── dns-zone/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── environments/
    ├── staging/
    └── production/

Provider Konfigürasyonu

Önce Cloudflare provider’ını tanımlayalım. providers.tf dosyasına şunları yazıyoruz:

terraform {
  required_version = ">= 1.0"

  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.0"
    }
  }

  # Remote state için S3 backend (opsiyonel ama önerilen)
  backend "s3" {
    bucket = "my-terraform-state"
    key    = "cloudflare-dns/terraform.tfstate"
    region = "eu-central-1"
  }
}

provider "cloudflare" {
  api_token = var.cloudflare_api_token
}

variables.tf dosyasına değişkenleri ekleyelim:

variable "cloudflare_api_token" {
  description = "Cloudflare API Token"
  type        = string
  sensitive   = true
}

variable "cloudflare_account_id" {
  description = "Cloudflare Account ID"
  type        = string
}

variable "domain" {
  description = "Ana domain adı"
  type        = string
  default     = "example.com"
}

terraform.tfvars dosyasında ise değerleri tanımlıyoruz. Bu dosyayı kesinlikle .gitignore‘a eklemeyi unutmayın:

cloudflare_account_id = "your_account_id_here"
domain                = "example.com"
# cloudflare_api_token değerini environment variable olarak set edin
# export TF_VAR_cloudflare_api_token="your_token_here"

Temel DNS Kayıtları Yönetimi

Şimdi asıl işe gelelim. main.tf dosyasına zone ve temel kayıtları ekleyelim:

# Mevcut zone'u data source olarak çek
data "cloudflare_zone" "main" {
  name = var.domain
}

# A Kaydı - Ana domain
resource "cloudflare_record" "root" {
  zone_id = data.cloudflare_zone.main.id
  name    = "@"
  value   = "203.0.113.10"
  type    = "A"
  ttl     = 1  # 1 = otomatik (proxied kayıtlar için)
  proxied = true
}

# www subdomain
resource "cloudflare_record" "www" {
  zone_id = data.cloudflare_zone.main.id
  name    = "www"
  value   = "203.0.113.10"
  type    = "A"
  ttl     = 1
  proxied = true
}

# API subdomain - proxied değil, direkt bağlantı
resource "cloudflare_record" "api" {
  zone_id = data.cloudflare_zone.main.id
  name    = "api"
  value   = "203.0.113.20"
  type    = "A"
  ttl     = 300
  proxied = false
}

# MX Kayıtları - Google Workspace için
resource "cloudflare_record" "mx_1" {
  zone_id  = data.cloudflare_zone.main.id
  name     = "@"
  value    = "aspmx.l.google.com"
  type     = "MX"
  ttl      = 3600
  priority = 1
}

resource "cloudflare_record" "mx_2" {
  zone_id  = data.cloudflare_zone.main.id
  name     = "@"
  value    = "alt1.aspmx.l.google.com"
  type     = "MX"
  ttl      = 3600
  priority = 5
}

# SPF Kaydı
resource "cloudflare_record" "spf" {
  zone_id = data.cloudflare_zone.main.id
  name    = "@"
  value   = "v=spf1 include:_spf.google.com ~all"
  type    = "TXT"
  ttl     = 3600
}

# DMARC Kaydı
resource "cloudflare_record" "dmarc" {
  zone_id = data.cloudflare_zone.main.id
  name    = "_dmarc"
  value   = "v=DMARC1; p=quarantine; rua=mailto:[email protected]"
  type    = "TXT"
  ttl     = 3600
}

Dinamik Kayıtlar ve Döngüler

Gerçek dünyada aynı pattern’i tekrarlamak zorunda kalırsınız. Diyelim ki birden fazla microservice subdomain’i eklemeniz gerekiyor. Bunu Terraform’un for_each özelliğiyle çok temiz şekilde halledebilirsiniz:

# Microservice tanımları
locals {
  microservices = {
    "auth" = {
      ip      = "10.0.1.10"
      proxied = false
      ttl     = 300
    }
    "payments" = {
      ip      = "10.0.1.11"
      proxied = false
      ttl     = 300
    }
    "notifications" = {
      ip      = "10.0.1.12"
      proxied = true
      ttl     = 1
    }
    "admin" = {
      ip      = "10.0.1.13"
      proxied = false
      ttl     = 120
    }
  }
}

resource "cloudflare_record" "microservices" {
  for_each = local.microservices

  zone_id = data.cloudflare_zone.main.id
  name    = each.key
  value   = each.value.ip
  type    = "A"
  ttl     = each.value.ttl
  proxied = each.value.proxied
}

Bu yaklaşım yeni bir microservice eklemenizi son derece kolaylaştırıyor. Sadece locals bloğuna yeni bir entry ekliyorsunuz, terraform apply diyorsunuz ve kayıt oluşuyor.

Cloudflare Page Rules ve Firewall Kuralları

DNS yönetiminin ötesine geçip Cloudflare’in diğer özelliklerini de Terraform ile yönetebilirsiniz. Bu, altyapınızın gerçek anlamda “as code” olması demek:

# HTTP'den HTTPS'e yönlendirme - Page Rule
resource "cloudflare_page_rule" "force_https" {
  zone_id  = data.cloudflare_zone.main.id
  target   = "http://${var.domain}/*"
  priority = 1

  actions {
    always_use_https = true
  }
}

# www olmadan ana domain'e yönlendirme
resource "cloudflare_page_rule" "www_redirect" {
  zone_id  = data.cloudflare_zone.main.id
  target   = "www.${var.domain}/*"
  priority = 2

  actions {
    forwarding_url {
      url         = "https://${var.domain}/$1"
      status_code = 301
    }
  }
}

# Bot koruması için Firewall Rule
resource "cloudflare_filter" "block_bad_bots" {
  zone_id     = data.cloudflare_zone.main.id
  description = "Kötü botları engelle"
  expression  = "(cf.client.bot) and not (cf.verified_bot_category in {"Search Engine Crawlers" "Monitoring & Analytics"})"
}

resource "cloudflare_firewall_rule" "block_bad_bots" {
  zone_id     = data.cloudflare_zone.main.id
  description = "Kötü bot engelleme kuralı"
  filter_id   = cloudflare_filter.block_bad_bots.id
  action      = "block"
  priority    = 1
}

Zone Ayarları ve SSL Konfigürasyonu

Cloudflare zone’un genel ayarlarını da Terraform ile yönetebilirsiniz. Bu, yeni bir zone oluştururken standart ayarları otomatik uygulamanıza olanak tanır:

# SSL modunu Full (Strict) olarak ayarla
resource "cloudflare_zone_settings_override" "main" {
  zone_id = data.cloudflare_zone.main.id

  settings {
    ssl                      = "strict"
    always_use_https         = "on"
    min_tls_version          = "1.2"
    tls_1_3                  = "zrt"
    automatic_https_rewrites = "on"
    opportunistic_encryption = "on"
    browser_cache_ttl        = 14400
    security_level           = "medium"
    
    # Saldırı altında mod için (normalde kapalı tutun)
    under_attack = "off"
    
    # HTTP/2 ve HTTP/3
    http2 = "on"
    http3 = "on"
    
    # Minification
    minify {
      css  = "on"
      js   = "on"
      html = "on"
    }
  }
}

Modüler Yapı: Birden Fazla Domain Yönetimi

Birden fazla domain yönetiyorsanız, modüler yapı hayat kurtarıcı. Önce modules/dns-zone/main.tf dosyasını oluşturalım:

# modules/dns-zone/variables.tf
variable "domain" {
  description = "Zone domain adı"
  type        = string
}

variable "a_records" {
  description = "A kayıtları listesi"
  type = map(object({
    value   = string
    proxied = bool
    ttl     = number
  }))
  default = {}
}

variable "cname_records" {
  description = "CNAME kayıtları listesi"
  type = map(object({
    value   = string
    proxied = bool
    ttl     = number
  }))
  default = {}
}

variable "mx_records" {
  description = "MX kayıtları"
  type = list(object({
    value    = string
    priority = number
    ttl      = number
  }))
  default = []
}

variable "txt_records" {
  description = "TXT kayıtları"
  type = map(object({
    name  = string
    value = string
    ttl   = number
  }))
  default = {}
}

Modülün ana dosyası modules/dns-zone/main.tf:

data "cloudflare_zone" "zone" {
  name = var.domain
}

resource "cloudflare_record" "a_records" {
  for_each = var.a_records

  zone_id = data.cloudflare_zone.zone.id
  name    = each.key
  value   = each.value.value
  type    = "A"
  ttl     = each.value.proxied ? 1 : each.value.ttl
  proxied = each.value.proxied
}

resource "cloudflare_record" "cname_records" {
  for_each = var.cname_records

  zone_id = data.cloudflare_zone.zone.id
  name    = each.key
  value   = each.value.value
  type    = "CNAME"
  ttl     = each.value.proxied ? 1 : each.value.ttl
  proxied = each.value.proxied
}

resource "cloudflare_record" "mx_records" {
  count = length(var.mx_records)

  zone_id  = data.cloudflare_zone.zone.id
  name     = "@"
  value    = var.mx_records[count.index].value
  type     = "MX"
  ttl      = var.mx_records[count.index].ttl
  priority = var.mx_records[count.index].priority
  proxied  = false
}

resource "cloudflare_record" "txt_records" {
  for_each = var.txt_records

  zone_id = data.cloudflare_zone.zone.id
  name    = each.value.name
  value   = each.value.value
  type    = "TXT"
  ttl     = each.value.ttl
  proxied = false
}

output "zone_id" {
  value = data.cloudflare_zone.zone.id
}

Modülü ana main.tf‘te kullanmak şöyle oluyor:

module "example_com" {
  source = "./modules/dns-zone"
  domain = "example.com"

  a_records = {
    "@" = {
      value   = "203.0.113.10"
      proxied = true
      ttl     = 1
    }
    "www" = {
      value   = "203.0.113.10"
      proxied = true
      ttl     = 1
    }
    "api" = {
      value   = "203.0.113.20"
      proxied = false
      ttl     = 300
    }
  }

  cname_records = {
    "mail" = {
      value   = "ghs.googlehosted.com"
      proxied = false
      ttl     = 3600
    }
  }

  mx_records = [
    {
      value    = "aspmx.l.google.com"
      priority = 1
      ttl      = 3600
    },
    {
      value    = "alt1.aspmx.l.google.com"
      priority = 5
      ttl      = 3600
    }
  ]

  txt_records = {
    "spf" = {
      name  = "@"
      value = "v=spf1 include:_spf.google.com ~all"
      ttl   = 3600
    }
    "dmarc" = {
      name  = "_dmarc"
      value = "v=DMARC1; p=quarantine; rua=mailto:[email protected]"
      ttl   = 3600
    }
  }
}

CI/CD Pipeline ile Otomatik Deploy

Gerçek güç, Terraform’u CI/CD pipeline’ına entegre ettiğinizde ortaya çıkıyor. GitHub Actions örneği:

# .github/workflows/terraform-dns.yml
name: Terraform DNS Deploy

on:
  push:
    branches: [main]
    paths:
      - '**.tf'
      - '**.tfvars'
  pull_request:
    branches: [main]

env:
  TF_VERSION: "1.6.0"

jobs:
  terraform-plan:
    name: Terraform Plan
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4

      - name: Terraform Setup
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init
        run: terraform init
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Terraform Format Check
        run: terraform fmt -check -recursive

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        run: terraform plan -out=tfplan
        env:
          TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          TF_VAR_cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

      - name: Plan Sonucunu Comment Olarak Yaz
        uses: actions/github-script@v7
        if: github.event_name == 'pull_request'
        with:
          script: |
            const output = `#### Terraform Plan Sonucu
            ```
            ${{ steps.plan.outputs.stdout }}
            ````;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })

  terraform-apply:
    name: Terraform Apply
    runs-on: ubuntu-latest
    needs: terraform-plan
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    
    steps:
      - uses: actions/checkout@v4

      - name: Terraform Setup
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init
        run: terraform init
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Terraform Apply
        run: terraform apply -auto-approve
        env:
          TF_VAR_cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          TF_VAR_cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

Mevcut DNS Kayıtlarını Import Etmek

Eğer zaten Cloudflare’de DNS kayıtlarınız varsa ve bunları Terraform yönetimine almak istiyorsanız, import komutunu kullanmanız gerekiyor. Bu biraz zahmetli ama bir kez yapıldıktan sonra her şey otomasyona giriyor:

# Önce mevcut kayıtları listele
# Cloudflare dashboard'dan Record ID'leri öğrenebilirsiniz
# Ya da Cloudflare API'yi kullanın:
curl -X GET "https://api.cloudflare.com/client/v4/zones/ZONE_ID/dns_records" 
  -H "Authorization: Bearer YOUR_API_TOKEN" 
  -H "Content-Type: application/json" | jq '.result[] | {id, name, type, content}'

# Terraform import komutu - her kayıt için ayrı ayrı yapmanız gerekiyor
terraform import cloudflare_record.root ZONE_ID/RECORD_ID
terraform import cloudflare_record.www ZONE_ID/RECORD_ID
terraform import cloudflare_record.api ZONE_ID/RECORD_ID

# Terraform 1.5+ ile import blokları kullanabilirsiniz (önerilen yeni yöntem)
# main.tf içine ekleyin:
import {
  to = cloudflare_record.root
  id = "ZONE_ID/RECORD_ID"
}

Import işleminden sonra terraform plan çalıştırdığınızda hiçbir değişiklik görmemeniz gerekiyor. Değişiklik görürseniz, yazdığınız kaynak tanımının mevcut durumla eşleşmediği anlamına geliyor, düzeltmeniz lazım.

Yaygın Hatalar ve Çözümleri

Terraform ile Cloudflare çalışırken sık karşılaşılan birkaç durumu paylaşayım:

  • Proxied kayıtta TTL çakışması: proxied = true olan kayıtlarda TTL değeri her zaman 1 (otomatik) olmalı. Farklı bir değer girseniz bile Cloudflare onu 1 olarak set ediyor ve Terraform sürekli “değişiklik var” gösteriyor. Çözüm: proxied kayıtlarda ttl = 1 kullanın.
  • MX kayıtlarında proxied: MX kayıtları hiçbir zaman proxied olamaz, proxied = false zorunlu. Aynı durum TXT ve SRV kayıtları için de geçerli.
  • State dosyasının kaybolması: Remote backend kullanmıyorsanız ve terraform.tfstate dosyasını kaybederseniz büyük sorun yaşarsınız. S3 + DynamoDB backend kurulumu biraz zaman alıyor ama kesinlikle değer.
  • API rate limiting: Çok sayıda kayıt oluştururken Cloudflare API rate limit’ine takılabilirsiniz. parallelism değerini düşürmek işe yarıyor: terraform apply -parallelism=5

Outputs Tanımlamak

outputs.tf dosyasına zone ID ve record bilgilerini eklemek, diğer Terraform modülleriyle entegrasyon için çok kullanışlı:

output "zone_id" {
  description = "Cloudflare Zone ID"
  value       = data.cloudflare_zone.main.id
}

output "nameservers" {
  description = "Zone nameserver'ları"
  value       = data.cloudflare_zone.main.name_servers
}

output "root_record_id" {
  description = "Ana domain A kaydı ID'si"
  value       = cloudflare_record.root.id
}

Sonuç

Terraform ile Cloudflare DNS yönetimi, başlangıçta biraz yatırım gerektiriyor. Import işlemleri, state yönetimi, CI/CD entegrasyonu… Bunları kurmak zaman alıyor. Ama bir kez oturttuğunuzda geri dönmek istemiyorsunuz.

Şu an yönettiğim ortamda 15’ten fazla domain, 300’ü aşkın DNS kaydı ve onlarca Cloudflare kuralı tamamen Terraform ile yönetiliyor. Bir kayıt değişikliği yapmam gerektiğinde PR açıyorum, ekip arkadaşım terraform plan çıktısını görüp onaylıyor, merge ediyorum ve GitHub Actions otomatik apply ediyor. Gece yarısı Cloudflare panelinde kayıt avı artık geçmişte kaldı.

Başlamak için büyük bir altyapıya ihtiyacınız yok. Tek bir domain, tek bir main.tf dosyası ve birkaç kayıtla başlayın. Yapının nasıl işlediğini anladıkça kademeli olarak genişletin. DNS gibi kritik bir altyapı bileşenini kod olarak yönetmek, sysadmin hayatınızın kalitesini ciddi ölçüde artırıyor.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir