Migrating Cloudflare to Terraform

A technical reference on migrating existing Cloudflare infrastructure to Terraform. Includes an automated bootstrapping script to bypass manual state imports and handle API edge cases.

WORDS: 1062 | CODE BLOCKS: 4 | EXT. LINKS: 1

A while back, I was tinkering in the Cloudflare dashboard and accidentally fat-fingered a DNS configuration. I didn’t realize the impact immediately, but I ended up taking down lorbic.com for an hour.

When I scrambled to fix it, I hit a wall: there was no “undo” button. There was no Git history to tell me what the record used to point to, and no review to catch the mistake before I blew it.

That was the day I realized that managing infrastructure by clicking around a UI, “Click-Ops” is a massive liability. And why everyone screams IaC, Terraform, Cloudformation etc. Moving to Infrastructure as Code (IaC) with Terraform was the obvious fix. But migrating an existing Cloudflare account with dozens of records and rules isn’t as simple as just writing the code.

Terraform Apply Cloudflare DNS

The 60-Second Terraform Crash Course

If you are new to DevOps, you only need to understand three core concepts to see why migrating existing infrastructure is notoriously difficult:

  1. The Code (.tf files): This is where you declare your desired infrastructure (e.g., “I want a CNAME record pointing to my server”).
  2. The Provider: The plugin that translates your code into Cloudflare API requests. They are available for almost every cloud provider on planet Earth and beyond (maybe!).
  3. The State (.tfstate): A JSON file where Terraform maps your code to the actual resources that already exist in the real world.

That third point is the bottleneck. If your .tfstate file is empty, Terraform assumes your Cloudflare account is empty. If you write your configuration and run terraform apply, Terraform tries to create brand new records. The Cloudflare API will immediately reject the request because those records already exist.

To fix this, you must explicitly link your code to your live infrastructure using terraform import. For a few dozen records across multiple domains, doing this by hand is a soul-crushing exercise.

Here is how I automated the entire generation and state import process.

The Target Architecture

Dumping hundreds of DNS records into a single main.tf file creates an unreadable configuration. A better approach is to use Terraform modules—one for the account-level resources (like Zero Trust), and one for each specific domain (zone).

The goal is a directory structure that looks like this:

text
 1cloudflare/
 2├── main.tf                # Provider config and module definitions
 3├── terraform.tfstate      # The single state file
 4├── account/               # Account-level config
 5│   ├── access_apps.tf
 6│   └── access_policies.tf
 7└── zones/                 
 8    ├── lorbic.com/        # Zone-specific config
 9    │   ├── dns.tf
10    │   └── rules.tf
11    └── vikashpatel.net/
12        ├── dns.tf
13        └── rules.tf

The root main.tf stays clean, simply tying the modules together:

hcl
 1# cloudflare/main.tf
 2module "lorbic_com" {
 3  source = "./zones/lorbic.com"
 4  providers = { cloudflare = cloudflare }
 5}
 6
 7module "vikashpatel_net" {
 8  source = "./zones/vikashpatel.net"
 9  providers = { cloudflare = cloudflare }
10}

The Automation Script

Cloudflare maintains a CLI tool called cf-terraforming. It queries your account and generates both the .tf configuration files and the shell commands required to import the state.

However, cf-terraforming import only outputs the raw terraform import commands. It does not execute them, and more importantly, it assumes a flat directory structure. Because we are using a modular architecture, the resource addresses must be prefixed with the module name.

Instead of running these manually, I wrote a bash script to loop through an array of my domains, generate the .tf files, parse the import output, remap the addresses to the correct modules, and execute the imports automatically.

Here is the core parsing logic from the script:

bash
 1# 1. Generate the Terraform config
 2cf-terraforming generate \
 3  --zone $ZONE_ID \
 4  --resource-type cloudflare_record > "zones/${DOMAIN}/dns.tf"
 5
 6# 2. Generate the import commands, parse them, and execute
 7cf-terraforming import \
 8  --zone $ZONE_ID \
 9  --resource-type cloudflare_record | while IFS= read -r line; do
10    
11  if [[ "$line" == terraform\ import\ * ]]; then
12    # Parse the raw output: "terraform import cloudflare_record.name <id>"
13    resource_addr=$(echo "$line" | awk '{print $3}')
14    import_id=$(echo "$line" | awk '{print $4}')
15
16    # Prefix with the module name (e.g., module.vikashpatel_net.cloudflare_record.name)
17    module_addr="module.${MODULE_NAME}.${resource_addr}"
18
19    # Execute the import
20    echo "Importing: $module_addr"
21    terraform import "$module_addr" "$import_id"
22  fi
23done

By wrapping this in a loop, you can bootstrap an entire account in seconds. Once the script finishes, running terraform plan should cleanly report that your infrastructure matches your configuration.

Edge Cases and API Limitations

Automated generation gets you 95% of the way there. During the migration, you will likely hit API quirks that require manual reconciliation.

1. The Read-Only Record Trap

Cloudflare manages certain DNS records automatically, such as Email Routing MX records or Zero Trust Access IPv6 CNAMEs.

If cf-terraforming exports these into your .tf files, your next terraform apply will fail with API Error 1046 or 1043. Terraform cannot assume ownership of records that Cloudflare provisions internally.

To fix it, you must manually delete these specific resource blocks from your generated .tf files.

2. Ruleset Index Conflicts

Cloudflare restricts accounts to a single “URL Normalization” ruleset per zone. Occasionally, the generation tool gets confused by naming conflicts, generating cloudflare_ruleset.transform_1 alongside cloudflare_ruleset.transform_2.

Attempting to apply this will result in an API rejection. You will need to manually reconcile these conflicts by deleting the duplicate block and using terraform state rm <resource_address> to clear it from the state file.

3. String Escaping and Normalization

Terraform is strict regarding state representation. Cloudflare’s API might return a TXT record with explicit quotes ("v=spf1 -all"), but the generated HCL might omit them or escape them differently.

During your first terraform plan, you may see dozens of ~ update in-place changes. This is Terraform normalizing the formatting. Review the diff carefully to ensure the actual values aren’t mutating, then apply it to sync the API with your local state format.

The Day-to-Day Workflow

Post-migration, the Cloudflare dashboard becomes strictly read-only. Manual UI changes will be overwritten the next time Terraform runs.

The new workflow relies entirely on the CLI:

bash
1# Verify changes against live infrastructure
2terraform -chdir=cloudflare plan
3
4# Apply changes defined in .tf files
5terraform -chdir=cloudflare apply

Migrating requires some upfront effort to handle API errors and organize the module structure. In exchange, the infrastructure becomes version-controlled, auditable, and predictably reproducible. You’ll never have to guess what a deleted DNS record used to look like again.

And honestly, having your entire DNS infrastructure version-controlled is incredibly satisfying to work with.