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_each over 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 plan flags this as a change.
  • Provider upgrades changing schema defaults. A field that was Optional + Computed in 4.x becomes Required in 5.x.

Mitigation:

  • Use for_each over map or set keyed by stable strings, never count over lists.
  • terraform plan -refresh=false to 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 941100 in the dashboard for a quick fix.
  • Next CI terraform apply silently 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_ruleset resources.
  • Run terraform plan on 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 version numbers.

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_deployment indefinitely 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:

Area4.x5.x
Page RulesFirst-class resourceDeprecated, redirected to Rulesets
cloudflare_filter + cloudflare_firewall_ruleTwo-resource patternRemoved — replaced by cloudflare_ruleset
Schema for nested blocksBlock syntaxOften shifted to attribute syntax
Required fieldsMany Optional + ComputedSeveral promoted to Required
AuthenticationAPI key fallbackAPI 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.www

Use 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

References