Terraform + Cloudflare

Use Terraform for Cloudflare the same way you use it anywhere else: scoped API tokens, remote state, plan in CI, and import before you rewrite working DNS.

Keep the blast radius small
  • 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 plan in 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:

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:

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:

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

SymptomLikely causeWhat 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.