Skip to content

Cloudflare Terraform Provider v4 → v5 — Migration Runbook

Scope: the four Terraform roots that pin the cloudflare/cloudflare provider. Issue #1679. The config rewrite ships in-repo; the state migration below must be executed by an operator with a live CLOUDFLARE_API_TOKEN — these roots manage LIVE DNS / WAF / status-page edge infrastructure, so read the whole runbook before running anything.

Migration status (2026-07-07) — COMPLETE

Root Live state? Outcome
platform/terraform/status-edge yes Migrated live 2026-07-06. moved state move, 0 destroy; re-plan = No changes.
platform/terraform/dns-caa yes Migrated live 2026-07-06. 4 CAA records moved in place, content byte-identical; re-plan = No changes.
pkg/octopus/deploy/terraform/multi-region no ✅ Nothing to migrate — never applied. Its old backend bucket (tlsstress-art-tfstate) does not exist and no bucket in the account holds a multi-region state key. Backend fixed to the project-standard bucket; the first ever apply will start life on v5.
platform/terraform/dns-subzone-delegation no ✅ Nothing to migrate — never applied. No acme/dns-subzone-delegation.tfstate key in the state bucket, and Route53 has zero hosted zones (the parent gw.tlsstress.art delegation zone doesn't exist yet). The first ever apply (needs gw_hosted_zone_id, cloudflare_account_id, customers tfvars) will start life on v5; the cloudflare_zone_subscription import below only applies to pre-existing zones, of which there are none.

Every root that manages live infrastructure has been migrated and re-plans clean. The remaining two roots have no state anywhere, so their "migration" is their initial deployment — a separate project decision, not a v4→v5 task.

Root Providers after migration
pkg/octopus/deploy/terraform/multi-region cloudflare ~> 5.21, aws ~> 6.53
platform/terraform/status-edge cloudflare ~> 5.21
platform/terraform/dns-caa cloudflare ~> 5.21
platform/terraform/dns-subzone-delegation cloudflare ~> 5.21, aws ~> 6.53

The aws bump to ~> 6.53 in multi-region and dns-subzone-delegation aligns those two roots with the rest of the repo (PR #1681) — they had been held back only by the Cloudflare v5 breakage.

What changed per root

pkg/octopus/deploy/terraform/multi-region

  • cloudflare_record.connect_anycast / cloudflare_record.signal_anycast renamed to cloudflare_dns_record (v5 resource rename). moved blocks are in main.tf, so state follows automatically.
  • Both cloudflare_ruleset resources (octopus_waf_managed, octopus_waf_custom): repeated rules { … } blocks became a single rules = [ { … } ] list attribute; action_parameters became a nested object. Attribute values are unchanged — same actions, same expressions, same managed-ruleset id.

platform/terraform/status-edge

  • cloudflare_record.status_acloudflare_dns_record.status_a (+ moved block). All record attributes unchanged (proxied A record, ttl = 1, same comment).
  • cloudflare_ruleset.cache: rules became a list attribute; action_parameters, edge_ttl and browser_ttl became nested objects. Semantics unchanged (edge TTL override 15 s, browser TTL bypass).

platform/terraform/dns-caa

  • cloudflare_record.caa_issue["…"] (×3) and cloudflare_record.caa_iodefcloudflare_dns_record (+ moved blocks covering every for_each instance). The CAA data { … } block became a data = { … } object — same flags / tag / value payload.

platform/terraform/dns-subzone-delegation

  • cloudflare_zone.customer: v4 zone argument → v5 name; v4 account_id → v5 account = { id = … }. Same resource type name — state upgrades in place, no address change.
  • The v4 plan argument was removed from cloudflare_zone upstream. The rate plan now lives in a NEW companion resource cloudflare_zone_subscription.customer with the exact same tier mapping (free tier → free, everything else → pro). For zones that already exist, import the subscription before the first apply (see below).
  • cloudflare_zone_dnssec.customer: v5 exposes the switch explicitly — status = "active" preserves the v4 create-time behavior.
  • cloudflare_api_token.customer: v4 policy { … } blocks → v5 policies = [ { … } ]; permission-group ids became objects (permission_groups = [{ id = … }]) and resources is now a JSON-encoded string (jsonencode({ … })). Same DNS-Write permission group id, same per-zone scoping.
  • Output customer_zones[*].zone_name now reads cloudflare_zone.customer[k].name (v5 renamed the zone attribute to name); the output shape is unchanged.

State migration

Renamed resources — automatic via moved blocks

Provider v5 (≥ 5.19; we pin ~> 5.21) implements Terraform's provider-defined move (MoveResourceState) for cloudflare_recordcloudflare_dns_record. With Terraform ≥ 1.8 (repo uses 1.15.x) the moved blocks already committed in each root migrate the state entries in place on the next plan/apply — no manual state surgery, no destroy/create. This is the same mechanism Cloudflare's own tf-migrate tool emits.

Same-name resources — automatic via state upgraders

cloudflare_ruleset, cloudflare_zone, cloudflare_zone_dnssec and cloudflare_api_token kept their type names; provider v5 ships built-in state upgraders that convert the v4 state format the first time the v5 provider reads it. Nothing to run.

New resource — cloudflare_zone_subscription (dns-subzone-delegation only)

For every customer zone that already exists in state, import the subscription so Terraform does not issue a redundant plan-set call:

cd platform/terraform/dns-subzone-delegation
export AWS_PROFILE=tlsstress-prod
export CLOUDFLARE_API_TOKEN=...   # platform token
terraform init -upgrade

# one import per existing customer key; zone_id from the customer_zones output
terraform import 'cloudflare_zone_subscription.customer["<customer_key>"]' '<zone_id>'

Fallback — manual state surgery (only if a moved block is rejected)

Should any tooling in the pipeline not honor the provider-defined move (e.g. a non-Terraform CLI), the equivalent manual path is remove + import. Record ids are already documented in each root's README:

# status-edge
terraform state rm cloudflare_record.status_a
terraform import cloudflare_dns_record.status_a "$ZONE/31de68523f9ce3eb44d39f7dfa812dd5"

# dns-caa
terraform state rm 'cloudflare_record.caa_issue["letsencrypt.org"]' \
                   'cloudflare_record.caa_issue["pki.goog"]' \
                   'cloudflare_record.caa_issue["amazon.com"]' \
                   cloudflare_record.caa_iodef
terraform import 'cloudflare_dns_record.caa_issue["letsencrypt.org"]' "$ZONE/d9260a33cacf58447acffe40ddd17da0"
terraform import 'cloudflare_dns_record.caa_issue["pki.goog"]'        "$ZONE/ddc9f69aa37316c91004372bd921cacc"
terraform import 'cloudflare_dns_record.caa_issue["amazon.com"]'      "$ZONE/9367272b025960f809ba33a74a980413"
terraform import  cloudflare_dns_record.caa_iodef                     "$ZONE/0744823ec83f55ce38abf63e7c6395e4"

# multi-region (record ids: look up via the Cloudflare API / dashboard)
terraform state rm cloudflare_record.connect_anycast cloudflare_record.signal_anycast
terraform import cloudflare_dns_record.connect_anycast "$ZONE/<record_id>"
terraform import cloudflare_dns_record.signal_anycast  "$ZONE/<record_id>"

Execution procedure (per root)

cd <root>
export CLOUDFLARE_API_TOKEN=...          # scoped per root README
terraform state pull > pre-v5-$(date +%Y%m%d).tfstate.backup   # rollback insurance
terraform init -upgrade
terraform plan -out=v5-migration.plan

Gate before apply — the plan MUST show:

  • # cloudflare_record.X has moved to cloudflare_dns_record.X lines for every renamed record;
  • 0 to add, 0 to change, 0 to destroy (multi-region/dns-subzone-delegation: additions are acceptable ONLY for cloudflare_zone_subscription imports you intentionally skipped);
  • NO destroy, NO replace (-/+) on any cloudflare_dns_record, cloudflare_ruleset, cloudflare_zone or cloudflare_api_token.

Quick check:

terraform show -no-color v5-migration.plan | grep -E 'has moved to|must be replaced|will be destroyed|Plan:'

If (and only if) the gate holds:

terraform apply v5-migration.plan
terraform plan   # second plan MUST be: No changes.

Rollback

  • Before apply: nothing was written — git revert the migration commit, terraform init -upgrade back to the v4 lockfile, done. moved blocks are plan-time only.
  • After apply: state now uses v5 addresses/formats. Restore the state snapshot taken above (terraform state push pre-v5-<date>.tfstate.backup) and git revert the config in the same change; then run terraform plan with v4 to confirm No changes. The S3 backend keeps versioned objects as a second recovery source.
  • The records/rulesets themselves are never touched by a correct migration — rollback is a config/state operation, not a DNS change.

Post-migration

  • The cloudflare ignore rule in .github/dependabot.yml is kept by decision (see #1679): it only blocks semver-major, so v5.x minors/patches flow normally now that every root pins ~> 5.21. What it guards against is an unattended v6 jump — the same permanent guardrail as the Node-LTS rule from #1678.
  • terraform plan in every root on the next scheduled drift check must report No changes.