Back to blog

Infrastructure as Code

Up and Running with the Cloudflare Terraform Provider

Quick-start guide to managing Cloudflare infrastructure with OpenTofu or Terraform.

October 1, 2025 Platform Engineering 4 min read

The Cloudflare Terraform provider gives you a clean way to manage DNS, security, redirects, and edge services using the same Infrastructure as Code workflow you already apply elsewhere. Keeping that configuration in version control makes changes easier to review, repeat, and roll back.

This post covers the basics of getting up and running, then highlights a few of the common edge cases that are worth understanding early.

Authentication and Setup

Modern Cloudflare authentication uses API tokens rather than legacy API keys. Tokens are easier to scope and far safer to manage in CI or local development.

Creating an API token

  1. Open the Cloudflare dashboard and go to My ProfileAPI Tokens
  2. Select Create Token
  3. Start from a template or define a custom token with the minimum required permissions
  4. Store the token securely

The provider can read credentials from environment variables, which helps keep sensitive values out of your repository and out of local configuration files:

export CLOUDFLARE_API_TOKEN="your-api-token-here"
terraform {
  required_version = "1.10.6"

  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5.0"
    }
  }
}

provider "cloudflare" {
  # Reads CLOUDFLARE_API_TOKEN from the environment
}

The same configuration works cleanly in OpenTofu by keeping the provider definition identical.

Managing Zones and DNS

A common pattern with Cloudflare is to treat the zone as the root object, then drive settings and records from variables so the configuration stays maintainable as it grows.

Zone

resource "cloudflare_zone" "example" {
  account = {
    id = var.cloudflare_account_id
  }

  zone = "example.com"
  type = "full"
}

Zone settings

variable "zone_settings" {
  description = "Cloudflare zone settings to configure"
  type        = map(any)
  default = {
    automatic_https_rewrites = "on"
    ssl                      = "full"
    always_use_https         = "on"
    min_tls_version          = "1.2"
    browser_check            = "on"
    brotli                   = "on"
  }
}

resource "cloudflare_zone_setting" "settings" {
  for_each = var.zone_settings

  zone_id    = cloudflare_zone.example.id
  setting_id = each.key
  value      = each.value
}

DNS records

variable "cloudflare_dns_records" {
  description = "DNS records to create in Cloudflare"
  type = map(object({
    type     = string
    name     = string
    content  = string
    ttl      = optional(number, 1)
    proxied  = optional(bool, false)
    priority = optional(number)
  }))
  default = {
    root = {
      type    = "CNAME"
      name    = "example.com"
      content = "site.pages.dev"
      ttl     = 1
      proxied = true
    }
    www = {
      type    = "CNAME"
      name    = "www"
      content = "site.pages.dev"
      ttl     = 1
      proxied = true
    }
    blog = {
      type    = "CNAME"
      name    = "blog"
      content = "blog.pages.dev"
      ttl     = 3600
      proxied = false
    }
  }
}

resource "cloudflare_dns_record" "dns_records" {
  for_each = var.cloudflare_dns_records

  zone_id  = cloudflare_zone.example.id
  type     = each.value.type
  name     = each.value.name
  content  = each.value.content
  ttl      = each.value.ttl
  proxied  = each.value.proxied
  priority = each.value.priority
}

That variable-driven approach is usually easier to scale than scattering individual record resources throughout a repository.

Page Rules and Rulesets

Cloudflare’s older Page Rules feature still exists in some environments, but for new work you should prefer Rulesets. They offer a more flexible expression-based model and are where most new Cloudflare traffic behaviour is being built.

A simple Page Rule might still look like this:

resource "cloudflare_page_rule" "static_cache" {
  zone_id  = cloudflare_zone.example.id
  target   = "static.example.com/*"
  priority = 1
  status   = "active"

  actions {
    cache_level    = "cache_everything"
    edge_cache_ttl = 86400
  }
}

For new infrastructure, a Ruleset is usually the better fit:

resource "cloudflare_ruleset" "redirects" {
  zone_id = cloudflare_zone.example.id
  kind    = "zone"
  phase   = "http_request_dynamic_redirect"
  name    = "redirects"

  rules = [
    {
      action = "redirect"
      action_parameters = {
        from_value = {
          preserve_query_string = false
          status_code           = 301
          target_url = {
            expression = "wildcard_replace(http.request.full_uri, r\"http://*\", r\"https://$${1}\")"
          }
        }
      }
      description = "Redirect HTTP to HTTPS"
      enabled     = true
      expression  = "(http.request.full_uri wildcard r\"http://*\")"
    },
    {
      action = "redirect"
      action_parameters = {
        from_value = {
          preserve_query_string = true
          status_code           = 301
          target_url = {
            expression = "wildcard_replace(http.request.full_uri, r\"https://www.*\", r\"https://$${1}\")"
          }
        }
      }
      description = "Redirect www to apex"
      enabled     = true
      expression  = "(http.request.full_uri wildcard r\"https://www.*\")"
    }
  ]
}

Using R2 as a Terraform Backend

Cloudflare R2 can also be a useful place to hold remote state, particularly if you are already operating inside the Cloudflare ecosystem.

Create the R2 bucket

resource "cloudflare_r2_bucket" "terraform_state" {
  account_id = var.cloudflare_account_id
  name       = "terraform-state-bucket"
  location   = "WEUR"
}

Configure the backend

terraform {
  backend "s3" {
    region   = "WEUR"
    bucket   = "terraform-state-bucket"
    key      = "example/terraform.tfstate"
    endpoint = "https://xxx.r2.cloudflarestorage.com"

    # Configure credentials locally, in CI, or through environment variables.
  }
}

That works because R2 exposes an S3-compatible API, which makes backend adoption much simpler than introducing a completely separate state system.

Common Pitfalls

Two issues come up often when teams first start managing Cloudflare with Terraform or OpenTofu.

Default zone rulesets

If you hit an error like:

'zone' is not a valid value for kind because exceeded maximum number of zone rulesets

Cloudflare may already have a default zone ruleset for that phase. In that case, import and manage the existing entrypoint instead of trying to create a second one.

curl -X GET \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets/phases/http_request_dynamic_redirect/entrypoint"

tofu import cloudflare_ruleset.redirects <zone-id>/<ruleset-id>

Provider v5 resource names

If you are upgrading from an older provider version, some resource names have changed. One common example is:

  • cloudflare_recordcloudflare_dns_record

Versioning the provider explicitly makes these migrations far easier to reason about.

Implementation Best Practices

The patterns that tend to hold up well are:

  1. use API tokens instead of legacy API keys
  2. keep credentials in environment variables or a secure secret manager
  3. pin the provider version
  4. use variable-driven structures for repeatable DNS and settings management
  5. use Rulesets for new traffic logic rather than building on Page Rules

Conclusion

The Cloudflare Terraform provider gives you a reliable way to treat edge infrastructure like the rest of your platform code. Once the basics are in place, you can manage zones, DNS, redirects, and supporting storage through the same reviewable IaC workflow you use elsewhere.

That consistency matters. It makes Cloudflare changes easier to audit, easier to reuse, and much less dependent on one person clicking around the dashboard.