Cloudflare Terraform Provider v4 → v5 — Migration Runbook¶
Scope: the four Terraform roots that pin the
cloudflare/cloudflareprovider. Issue #1679. The config rewrite ships in-repo; the state migration below must be executed by an operator with a liveCLOUDFLARE_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_anycastrenamed tocloudflare_dns_record(v5 resource rename).movedblocks are inmain.tf, so state follows automatically.- Both
cloudflare_rulesetresources (octopus_waf_managed,octopus_waf_custom): repeatedrules { … }blocks became a singlerules = [ { … } ]list attribute;action_parametersbecame a nested object. Attribute values are unchanged — same actions, same expressions, same managed-ruleset id.
platform/terraform/status-edge¶
cloudflare_record.status_a→cloudflare_dns_record.status_a(+movedblock). All record attributes unchanged (proxied A record,ttl = 1, same comment).cloudflare_ruleset.cache:rulesbecame a list attribute;action_parameters,edge_ttlandbrowser_ttlbecame nested objects. Semantics unchanged (edge TTL override 15 s, browser TTL bypass).
platform/terraform/dns-caa¶
cloudflare_record.caa_issue["…"](×3) andcloudflare_record.caa_iodef→cloudflare_dns_record(+movedblocks covering everyfor_eachinstance). The CAAdata { … }block became adata = { … }object — sameflags/tag/valuepayload.
platform/terraform/dns-subzone-delegation¶
cloudflare_zone.customer: v4zoneargument → v5name; v4account_id→ v5account = { id = … }. Same resource type name — state upgrades in place, no address change.- The v4
planargument was removed fromcloudflare_zoneupstream. The rate plan now lives in a NEW companion resourcecloudflare_zone_subscription.customerwith the exact same tier mapping (freetier →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: v4policy { … }blocks → v5policies = [ { … } ]; permission-group ids became objects (permission_groups = [{ id = … }]) andresourcesis now a JSON-encoded string (jsonencode({ … })). Same DNS-Write permission group id, same per-zone scoping.- Output
customer_zones[*].zone_namenow readscloudflare_zone.customer[k].name(v5 renamed thezoneattribute toname); 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_record → cloudflare_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.Xlines for every renamed record;0 to add, 0 to change, 0 to destroy(multi-region/dns-subzone-delegation: additions are acceptable ONLY forcloudflare_zone_subscriptionimports you intentionally skipped);- NO
destroy, NOreplace(-/+) on anycloudflare_dns_record,cloudflare_ruleset,cloudflare_zoneorcloudflare_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 revertthe migration commit,terraform init -upgradeback to the v4 lockfile, done.movedblocks 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) andgit revertthe config in the same change; then runterraform planwith v4 to confirmNo 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
cloudflareignore rule in.github/dependabot.ymlis kept by decision (see #1679): it only blockssemver-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 planin every root on the next scheduled drift check must reportNo changes.