Terraform + Cloudflare
- Use a least-privilege API token, never the global API key.
- Keep separate Terraform state per environment or zone. DNS mistakes are instant and public.
- Import existing records before managing them, or Terraform will try to create duplicates beside real traffic.
- Run
terraform planin CI on every merge request and treat drift as a real operational signal. - Test WAF and edge rules on a narrow hostname or path first; broad expressions break real users fast.
Provider setup and API tokens
The Cloudflare provider should get credentials from the environment, not from committed HCL. A common pattern is one Terraform root per zone or per environment, backed by remote state.
terraform {
required_version = "~> 1.9"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.52"
}
}
backend "s3" {
bucket = "mycorp-tfstate-prod"
key = "cloudflare/example-com.tfstate"
region = "eu-west-1"
dynamodb_table = "tf-lock"
encrypt = true
}
}
provider "cloudflare" {}
variable "cloudflare_account_id" {
type = string
}
data "cloudflare_zone" "example" {
name = "example.com"
}
export CLOUDFLARE_API_TOKEN="redacted"
terraform init
terraform plan
Token scopes depend on what you manage. Typical minimum set:
- Zone / DNS / Edit for records
- Zone / Zone / Read for data lookups
- Account / Cloudflare Pages / Edit for Pages projects
- Zone / WAF / Edit for custom rulesets
Store the token in a protected CI secret or fetch it from HashiCorp Vault; the general pattern is covered in GitLab Secrets & OIDC.
DNS records and zone lookups
Most real Terraform + Cloudflare work starts with records. Let Terraform look up the zone ID and manage records by intent, not by hard-coded UUIDs.
resource "cloudflare_record" "www" {
zone_id = data.cloudflare_zone.example.id
name = "www"
type = "CNAME"
content = "sysref.pages.dev"
proxied = true
ttl = 1
}
resource "cloudflare_record" "mx" {
zone_id = data.cloudflare_zone.example.id
name = "example.com"
type = "MX"
content = "mail.example.com"
priority = 10
proxied = false
ttl = 3600
}
Operator notes:
ttl = 1means "automatic" in Cloudflare, not one second.- Only HTTP-ish records can be proxied. Mail, SSH, and many verification records must stay
proxied = false. - Keep one obvious owner for apex records, MX, and TXT verification records. These are the ones humans edit in the UI at 02:00.
Cloudflare Pages
Pages works well with Terraform for the project object, deployment settings, and custom domain bindings. The actual site content still comes from the repo and build pipeline.
resource "cloudflare_pages_project" "sysref" {
account_id = var.cloudflare_account_id
name = "sysref"
production_branch = "main"
build_config {
build_command = "npm run build"
destination_dir = "dist"
}
}
resource "cloudflare_pages_domain" "docs" {
account_id = var.cloudflare_account_id
project_name = cloudflare_pages_project.sysref.name
domain = "docs.example.com"
}
A common pairing is a Pages custom domain plus a Terraform-managed DNS record for related service names. Keep them in the same state file only if the lifecycle is the same; otherwise split them so a Pages change cannot accidentally take DNS with it.
WAF and edge rules
Cloudflare WAF changes are just infrastructure changes with faster feedback. Keep rules explicit, narrow, and readable. If you cannot explain the expression in one sentence, it is probably too broad.
resource "cloudflare_ruleset" "custom_waf" {
zone_id = data.cloudflare_zone.example.id
name = "sysref custom firewall"
kind = "zone"
phase = "http_request_firewall_custom"
rules {
description = "Block obvious WordPress probes on docs host"
expression = "(http.host eq \"docs.example.com\" and starts_with(http.request.uri.path, \"/wp-\"))"
action = "block"
enabled = true
}
rules {
description = "Challenge repeated login POSTs"
expression = "(http.request.uri.path eq \"/login\" and http.request.method eq \"POST\")"
action = "managed_challenge"
enabled = true
}
}
Design guidance for edge rules is covered in CDN / WAF Concepts. Terraform is the right place for repeatable WAF policy; the UI is where emergency exceptions tend to appear first, so expect drift and clean it up deliberately.
State handling, import, and drift
Cloudflare is especially drift-prone because operators change records and page settings in the UI under pressure. That does not mean Terraform is wrong; it means you need a clean import-and-plan workflow.
# Import an existing record before managing it
terraform import cloudflare_record.www <zone_id>/<record_id>
# Check drift in CI or on a schedule
terraform plan -detailed-exitcode
# 0 = no diff, 1 = error, 2 = drift or pending change
# See what Terraform thinks it owns
terraform state list
Practical state rules:
- Do not commit
terraform.tfstateto Git. - Use remote state with locking exactly as described in Terraform Basics.
- Separate prod and non-prod Cloudflare state. The DNS zone is not a good place to discover workspace mistakes.
- If a record was created manually, import it first instead of deleting and recreating it during business hours.
CI usage
CI should validate, plan, and usually require a human gate before apply. The critical part is that the Cloudflare token comes from a secret store, not from the repo.
plan-cloudflare:
stage: plan
image: hashicorp/terraform:1.9
variables:
TF_IN_AUTOMATION: "true"
script:
- terraform init -input=false
- terraform fmt -check
- terraform validate
- terraform plan -input=false -out=tfplan
artifacts:
paths:
- tfplan
apply-cloudflare:
stage: deploy
image: hashicorp/terraform:1.9
when: manual
script:
- terraform init -input=false
- terraform apply -input=false tfplan
If you need to get the token from Vault first, use the same short-lived secret workflow described in GitLab Secrets & OIDC. For teams already using Terraform heavily, keep the Cloudflare root modules small and reviewable rather than building a giant "edge" megaproject.
Troubleshooting and failure modes
| Symptom | Likely cause | What to do |
|---|---|---|
| HTTP 403 or provider auth errors | Token missing scope or targeting the wrong account. | Review token permissions and confirm the account and zone really match the state. |
| Terraform wants to create a record that already exists | The record was created manually and is not in state. | Import it instead of deleting live DNS to satisfy Terraform. |
| Pages custom domain stays pending | Domain verification or related DNS records are not settled yet. | Check the Pages project in the UI, confirm DNS, and wait for validation before declaring failure. |
| Mail or verification records stop working | A record was accidentally set to proxied = true. |
Set non-HTTP records to proxied = false and re-apply. |
| WAF rule blocks real users | Expression is too broad or was promoted without a canary. | Narrow the hostname or path match, and compare with the guidance in CDN / WAF Concepts. |
| Unexpected plan after a quiet week | Someone changed Cloudflare in the UI. | Review the drift, decide whether Terraform or the manual change is now authoritative, then reconcile intentionally. |
| Wrong zone or record targeted in apply | State isolation is poor or variables point to the wrong environment. | Split the roots, pin variables, and make the plan output part of review before apply. |
Related pages: Terraform Basics, CDN / WAF Concepts, GitLab Secrets & OIDC, GitLab CI/CD.