One misconfigured proxied flag in a terraform cloudflare dns config can silently take down staging SSH or VPN access — nobody notices until someone tries to connect and gets a timeout instead of a connection refused. We hit exactly this last quarter: a staging record got flipped to proxied = true during a “quick fix” in the console, Cloudflare started intercepting non-HTTP(S) traffic on port 22, and it took us forty minutes to figure out why our bastion host was unreachable. That incident is the reason this checklist exists.
Why this checklist

Managing DNS through the Cloudflare dashboard feels fast right up until you have three environments and two people editing records independently. Someone adds a TXT record for a verification step in prod, forgets to mirror it in Terraform, and six months later a terraform apply deletes it because it’s not in state and someone ran an aggressive cleanup. That’s drift, and it’s the single biggest source of “why did DNS break” incidents I’ve debugged.
The other reason this needs to be a checklist and not a one-time setup doc: Cloudflare’s API has real constraints that bite you as you scale past a handful of records. Rate limits sit around 1200 requests per 5 minutes per token — fine for 20 records, painful for a for_each loop managing 150 of them during a bulk apply. Zone-level permissions also matter more than people expect; a token scoped too broadly in CI is a liability, and one scoped too narrowly breaks silently with a 403 that doesn’t explain itself well.
So this isn’t “how to set up the Cloudflare provider once.” It’s the list of checks we run before every terraform apply that touches a Cloudflare zone, across dev, staging, and prod. If you’re managing infrastructure across multiple environments generally, this pairs well with the state isolation patterns we’ve covered on kuryzhev.cloud for other providers — the same discipline applies to DNS, it’s just less forgiving of mistakes because DNS failures are user-facing immediately.
The checklist (numbered)
Run through these in order. Steps 1-5 are about provider and access hygiene, 6-10 are about the actual plan, 11-14 are about state integrity.
- Pin the provider version. Use
cloudflare/cloudflare ~> 4.20explicitly. The v3 to v4 migration renamed severalcloudflare_recordattributes (notablyvaluebecamecontent), and an unpinned provider will happily upgrade mid-CI-run and break your plan with confusing attribute errors. - Verify the API token scope. It should be
Zone:Read+DNS:Editfor the specific zone only. Never use the legacy Global API Key in CI — it has account-wide access and no audit granularity if it leaks. - Confirm the token lives in secrets, not tfvars. Set it via
TF_VAR_cloudflare_api_tokenin GitHub Actions secrets or Terraform Cloud, never committed in a.tfvarsfile. - Check state isolation per environment. Separate backend configs, e.g.
backend "s3" { key = "cloudflare/dev/terraform.tfstate" }, not one shared state with workspaces unless you’ve deliberately chosen that pattern and tested it. - Confirm zone_id is data-sourced, not hardcoded. Hardcoded zone IDs break the moment you move to a new Cloudflare account or split a zone.
- Review the tfvars structure.
dev.tfvars,staging.tfvars,prod.tfvarsshould each set their ownzone_id— don’t reuse one variable file’s zone across environments. - Check record naming conventions. Are subdomains consistent (
api.staging.example.comvsstaging-api.example.com)? Inconsistency here makes audits painful later. - Verify TTL and proxied consistency. If
proxied = true, TTL must be1(automatic) — anything else is silently ignored by the API. - Read the full plan diff before apply. Not just the resource count — actually read what’s changing on each record.
- Check for orphaned records not in state. Run a zone audit against Terraform state to catch console-created drift.
- Detect CNAME/A conflicts. Cloudflare will reject overlapping records, but catching it in plan is faster than in apply.
- Confirm wildcard records have explicit targets.
*.staging.example.comneeds an explicit A/CNAME target and won’t fall back to the apex. - Audit state list.
terraform state list | grep cloudflare_record— compare count against what you expect to be managed. - Check the comment/tag field. Every managed record should carry a traceable comment like
managed-by-terraform:stagingso anyone in the dashboard knows not to touch it manually.
Here’s the module we use to enforce most of these structurally, so the checklist becomes partly self-verifying:
# modules/dns-records/main.tf
# Reusable module for per-environment DNS record management
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.20"
}
}
}
variable "zone_id" {
description = "Cloudflare zone ID for this environment"
type = string
}
variable "environment" {
description = "dev | staging | prod - used for naming and lifecycle rules"
type = string
}
variable "records" {
description = "Map of DNS records to manage"
type = map(object({
name = string
type = string
value = string
ttl = number
proxied = bool
}))
}
resource "cloudflare_record" "this" {
for_each = var.records
zone_id = var.zone_id
name = each.value.name
type = each.value.type
content = each.value.value
# TTL must be 1 (automatic) when proxied = true, else Cloudflare ignores custom TTL
ttl = each.value.proxied ? 1 : each.value.ttl
proxied = each.value.proxied
# Protect prod apex/MX records from accidental destroy during refactors
lifecycle {
prevent_destroy = var.environment == "prod" ? true : false
}
comment = "managed-by-terraform:${var.environment}"
}
output "record_hostnames" {
value = { for k, r in cloudflare_record.this : k => r.hostname }
}
And the environment wiring that plugs into it, with the staging proxied = false flag that would have saved us from the SSH incident:
# envs/staging/main.tf
# Environment-specific wiring - separate state, separate tfvars
module "dns_records" {
source = "../../modules/dns-records"
zone_id = var.zone_id
environment = "staging"
records = {
api = {
name = "api.staging"
type = "CNAME"
value = "staging-lb.example.com"
ttl = 300
proxied = false # direct traffic, not CDN-proxied - avoids SSH/VPN block
}
web = {
name = "staging"
type = "A"
value = "203.0.113.10"
ttl = 1
proxied = true
}
}
}
# terraform.tfvars for this env (kept out of git for zone_id sensitivity)
# zone_id = "abc123def456..."
# Expected plan output snippet after `terraform plan`:
# # module.dns_records.cloudflare_record.this["api"] will be created
# + resource "cloudflare_record" "this" {
# + content = "staging-lb.example.com"
# + proxied = false
# + ttl = 300
# + type = "CNAME"
# }
Commonly missed items
The checklist above catches the obvious stuff. These are the things that pass code review clean and still break something two weeks later.
Proxied vs non-proxied mismatches. This is the one that gets everyone eventually. Turning on proxied = true for anything that isn’t HTTP/HTTPS traffic — SSH, VPN, custom TCP services — means Cloudflare’s edge intercepts and blocks it. There’s no clean error message; the connection just times out. If a record needs to serve raw TCP, keep it grey-cloud.
TTL=1 behavior with proxied records. Set a TTL of 300 with proxied = true and Terraform will show it in the plan, apply it without error, and Cloudflare will quietly ignore it because proxied records always use automatic TTL. You won’t get a warning — you’ll just wonder why your plan never shows drift on that field again.
Forgotten MX/TXT/SPF records. Teams often manage A and CNAME records in Terraform but leave email-related records console-managed “because they rarely change.” That’s exactly how partial drift creeps in — and TXT records over 255 characters (common with DKIM keys) throw Error 1004: DNS Validation Error if you don’t chunk them properly.
Hardcoded zone_id. I’ve seen this break an account migration because the zone ID was baked into three different tfvars files instead of being looked up via a cloudflare_zone data source. Fixing it after the fact means touching every environment’s config.
Apex record flattening and proxied staging subdomains. Root domain CNAMEs get flattened by Cloudflare automatically, which is convenient until you’re debugging why a record behaves differently than a subdomain with the identical config. And watch your billing — proxying staging traffic through Cloudflare’s CDN on Business/Enterprise plans can push you into higher bandwidth tiers for traffic that never needed to touch the CDN in the first place.
Automation ideas
A checklist you run manually eventually gets skipped under deadline pressure. The fix is to enforce as much of it as possible in CI.
Start with a pre-commit hook running terraform fmt -check, terraform validate, and tflint with a Cloudflare-specific ruleset — this catches the TTL/proxied mismatch and missing zone_id data sources before a human even opens the PR. See the Cloudflare Terraform provider docs for the current resource schema, since it changes between major versions.
Next, run a GitHub Actions matrix job that executes terraform plan per environment directory on a schedule, using -detailed-exitcode to flag drift automatically:
# .github/workflows/dns-drift-check.yml
name: cloudflare-dns-drift
on:
schedule:
- cron: "0 6 * * *" # daily drift check
jobs:
plan:
strategy:
matrix:
env: [dev, staging, prod]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Plan
working-directory: envs/${{ matrix.env }}
run: |
terraform init
terraform plan -detailed-exitcode -out=plan.tfplan
env:
TF_VAR_cloudflare_api_token: ${{ secrets.CF_API_TOKEN }}
Finally, write a small diffing script that pulls live zone records via the Cloudflare API and compares them against terraform show -json plan.tfplan | jq output weekly. This is what actually catches the console-made changes that no plan will ever surface on its own — someone editing a record directly doesn’t touch your state file, so only a live-vs-state comparison finds it. Combine that with the Cloudflare API reference for the exact fields you need to diff against your resource attributes, and you’ve turned this checklist into something that runs itself instead of relying on someone remembering it at 5pm on a Friday before an apply. That’s the real goal of a terraform cloudflare dns checklist — not catching mistakes after the fact, but making the mistakes structurally hard to make.
