cloudflare terraform iac devops claude-curated
The Cloudflare Terraform provider (cloudflare/cloudflare) covers most of the Cloudflare API, but several behaviors regularly surprise practitioners. Understanding them up-front prevents production incidents.
1. Unrelated Resource Recreation on Small Changes
Editing one DNS record can show plans that destroy and recreate logically unrelated resources — WAF rules, IP allowlists, Custom Hostnames, even Page Rules. Common causes:
for_eachover a list that lost or reordered an element. When list-derived keys shift, every downstream resource keyed off the index is recreated.- Computed attributes that drift. Cloudflare returns timestamps, IDs, or normalized values that don’t match the input —
terraform planflags this as a change. - Provider upgrades changing schema defaults. A field that was
Optional + Computedin 4.x becomesRequiredin 5.x.
Mitigation:
- Use
for_eachovermaporsetkeyed by stable strings, nevercountover lists. terraform plan -refresh=falseto see only intended changes.- Inspect plans line-by-line; never apply destructive plans without reading them.
2. WAF Managed Rule Overrides — Dashboard Drift
Cloudflare’s dashboard happily lets users override managed rule actions (e.g., disable a CRS rule, change Block → Log). These overrides are stored in Cloudflare’s API but are not reflected back into Terraform state automatically — see Terraform State Drift.
Symptoms:
- Engineer disables rule
941100in the dashboard for a quick fix. - Next CI
terraform applysilently re-enables it. - The site breaks again the next morning.
Mitigation:
- Treat the dashboard as read-only for any TF-managed resource. Enforce via IAM where possible, applying Least Privilages.
- For managed rulesets, define overrides explicitly in
cloudflare_rulesetresources. - Run
terraform planon a schedule and alert on drift.
3. Rulesets — TF State vs Dashboard Drift
The Rulesets engine (cloudflare_ruleset) is particularly prone to drift because:
- Cloudflare assigns auto-generated IDs to rules that may change on update.
- Rule ordering is significant and rebuilt by every apply.
- The provider sometimes re-emits computed
versionnumbers.
A terraform plan against an unchanged resource can still show diffs. Key fields to watch:
resource "cloudflare_ruleset" "waf_custom" {
zone_id = var.zone_id
name = "Custom WAF"
kind = "zone"
phase = "http_request_firewall_custom"
rules {
# Don't depend on `id` — it's computed and may shift
action = "block"
expression = "(http.host eq \"app.example.com\" and not ip.src in $office)"
description = "Block non-office IPs to admin"
enabled = true
}
}4. Custom Hostname State vs Reality
cloudflare_custom_hostname resources frequently desynchronize with the API:
- The hostname’s underlying ID can change when Cloudflare re-issues internally.
- SSL validation status (
pending_validation→active) is a runtime state, not a config — Terraform cannot “wait” for it natively. - Customer-side DNS changes (CNAMEs going stale, ACME records removed) leave hostnames in
pending_deploymentindefinitely with no signal in Terraform.
Mitigation:
- Don’t manage the per-customer custom hostname lifecycle in long-running Terraform — use a service that calls the API directly.
- If you must, use
lifecycle { ignore_changes = [ssl] }to stop fighting transient state.
5. Provider Version Pinning — 4.x vs 5.x
The 4.x → 5.x jump is not backward compatible. Major changes:
| Area | 4.x | 5.x |
|---|---|---|
| Page Rules | First-class resource | Deprecated, redirected to Rulesets |
cloudflare_filter + cloudflare_firewall_rule | Two-resource pattern | Removed — replaced by cloudflare_ruleset |
| Schema for nested blocks | Block syntax | Often shifted to attribute syntax |
| Required fields | Many Optional + Computed | Several promoted to Required |
| Authentication | API key fallback | API token only |
Always pin the provider version explicitly:
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.40" # or "~> 5.0" — never unpinned
}
}
}Migrating 4.x → 5.x typically requires a state-import campaign per resource type.
Workarounds and Patterns
Targeted Apply
Apply only the resources you intend to change:
terraform apply -target=cloudflare_record.api -target=cloudflare_record.wwwUse sparingly — -target skips the dependency graph, which can break invariants. Acceptable for emergencies.
State Import
When dashboard changes have created drift you want to keep (see Terraform Import Block):
terraform import cloudflare_ruleset.waf_custom <zone_id>/<ruleset_id>Then reconcile config to match.
Lifecycle Blocks
Stop Terraform from fighting computed/runtime state:
resource "cloudflare_custom_hostname" "tenant" {
# ...
lifecycle {
ignore_changes = [ssl, status]
}
}Split State Files
Separate rarely-changing infra (zones, baseline WAF, account-wide rulesets) from frequently-changing infra (DNS records, per-tenant resources). Cross-state references via terraform_remote_state.
This isolates blast radius — a churn-prone DNS module can’t recreate your WAF.
CI Drift Detection
Run terraform plan -detailed-exitcode on a cron. Exit code 2 means drift — alert and investigate before the next apply masks it. Pair with Terraform Cloud Workspaces for hosted runs or Terragrunt for multi-account orchestration.
See also
- Cloudflare WAF
- Cloudflare for SaaS
- GitOps
- DNS
- Terraform
- Terragrunt
- Terraform State Drift
- Terraform Cloud Workspaces
- Terraform Import Block
- Cloudflare Plans
- Monitoring Cloudflare Security Events