Managing Cloudflare at scale with Terraform

Configuration management gets harder as organizations grow beyond a small group of administrators. Cloudflare accounts are no exception: with dozens of products and hundreds of API endpoints, keeping track of current settings and making bulk changes across multiple zones becomes unwieldy. The Dashboard is fine for exploration and analytics, but changes that could affect users deserve a code review before they go live.

That's where cloudflare-go's Terraform provider comes in. Built on top of the Cloudflare API, it lets teams manage configuration through stateful Terraform resource declarations. Cloudflare not only supports this provider for customers but uses it extensively internally. Below are some lessons learned from that dogfooding.

Why move configuration into code

Cloudflare runs its own internal services on its products — DNS, WAF, Zero Trust, Email Security, Workers, and experimental features. Early on, a handful of accounts with designated administrators sufficed. As the company grew, so did the number of accounts (now in the hundreds), each owned by separate teams. Independent accounts let service owners make changes without affecting others, but they also introduced overhead: inconsistent security policies, stale account memberships after team transfers, and a reliance on audit logs when something broke during a migration. Manual changes were often double-checked over video calls.

Defining configuration in Terraform addressed those problems by making changes auditable and self-service. Internal benefits included:

  • Peer review of all account modifications by the owning team.
  • Every change tied to a user, commit, and ticket.
  • API tokens bound to service accounts, surviving team changes and offboarding.
  • Account configuration auditable by anyone at the company without granting broad account access.
  • Large-scale changes like enforcing hard keys applied rapidly, sometimes in a single pull request.
  • Configuration easily copied across accounts to propagate best practices.

Terraform in CI/CD

Atlantis provides CI/CD for Terraform, fitting into version control workflows and making plans visible in pull requests. When a PR is opened, a webhook triggers a terraform plan, and the resulting comment appears in the PR. The change can't be applied until it's approved; after approval, a comment like atlantis apply runs the apply, with output posted back to the PR.

Using Atlantis eliminates local state fiddling and question about where a state lock originates. It makes configuration management approachable even for non-specialists.

The internal setup is a monorepo with one directory and tfstate per Cloudflare account. This centralizes oversight while keeping accounts neatly separated. Teams map onto accounts and directories via CODEOWNERS, so they're tagged on relevant PRs. Separate tfstates mean fewer lock contention issues, and account-sized states stay small enough for fast builds.

Since tfstates include secrets like API keys, each state is stored encrypted in an internal datastore. On a PR event, Atlantis calls middleware that retrieves and decrypts the state for processing; after apply, the state is encrypted and stored again.

To catch drift and rotate expiring certificates, a daily Terraform apply runs across all states. This keeps pull request diffs clean and avoids unexpected reverts. Daily frequency balances drift enforcement against lock contention while users run plans in PRs.

Preventing dashboard drift

During the transition, users still edited settings in the Dashboard, creating a second source of truth. Changes made there would often be mysteriously reverted by the next day's apply. To address this, Cloudflare introduced an API/Terraform read-only mode toggle in the Zero Trust Dashboard. It politely blocks manual Zero Trust configuration changes without stripping permissions from users who need break-glass access. The setting can be enabled via the Zero Trust organization API.

Having this toggle completed the loop: configuration is managed in code, reviewed in PRs, applied through Atlantis, and dashboard edits are prevented so the code remains the single source of truth.

Patterns That Keep the Repository Maintainable

Maintaining a Terraform repository that stays responsive and understandable requires deliberate choices about how resources are defined. Too much abstraction creates a debugging nightmare; too little leads to endless copy-pasting. The balance Cloudflare has settled on leans heavily on readable, explicit resources and targeted for_each loops rather than wrapper modules.

Account Membership at a Glance

Managing access to a Cloudflare account can be reduced to a simple mapping between user emails and their roles. The provider's account_member resource handles this directly. The role names come from the account_roles data source, so the configuration can list human-friendly titles rather than opaque IDs, making permission assignments legible to anyone reviewing the code.

A newer argument, status, lets administrators add accounts without triggering an invitation email. This is particularly relevant for organizations that rely on single sign-on, where the email-based invitation flow adds friction without providing much value.

variables.tf
—-
data "cloudflare_account_roles" "my_account" {
	account_id = var.account_id
}

locals {
  roles = {
	for role in data.cloudflare_account_roles.my_account.roles :
  	role.name => role
  }
}

members.tf
—-
locals {
  users = {
    emerson = {
      roles = [
        local.roles["Administrator"].id
      ]
    }
    lucian = {
      roles = [
        local.roles["Super Administrator - All Privileges"].id
      ]
    }
    walruto = {
      roles = [
        local.roles_by_name["Audit Logs Viewer"].id,
        local.roles_by_name["Cloudflare Access"].id,
        local.roles_by_name["DNS"].id
      ]
  }
}

resource "cloudflare_account_member" "account_member" {
  for_each  	= local.users
  account_id	= var.account_id
  email_address = "${each.key}@cloudflare.com"
  role_ids  	= each.value.roles
  status            = "accepted"
}

Self-Refreshing Service Tokens for Access

A common pattern inside Cloudflare involves services connecting to hostnames protected by Access. The provider now supports automatic rotation of service tokens, a feature that grew out of an internal requirement. The implementation itself is straightforward: define the set of services that need access, create a token for each one, and store those credentials in a secret store — Vault in Cloudflare's case, though any provider works. The token IDs then get referenced in the relevant Access policies.

Once the Terraform configuration has run, the service or its owner can fetch the current credentials from the data store. The provider handles the refresh cycle, removing the operational burden of manually rotating tokens before they expire.

tokens.tf
—
locals {
  service_tokens = toset([
    "customer-service",     # TICKET-120
    "full-service",               # TICKET-128
    "quality-of-service"      # TICKET-420 
    "room-service"            # TICKET-927
  ])
}

resource "cloudflare_access_service_token" "token" {
  for_each   = local.service_tokens
  account_id = var.account_id
  name   	= each.key
  min_days_for_renewal = 30
}

resource "vault_generic_secret" "access_service_token" {
  for_each   = local.service_tokens
  path = "kv/secrets/${each.key}/access_service_token"
  disable_read = true

  data_json = jsonencode({
	client_id        = cloudflare_access_service_token.token["${each.key}"].client_id,
client_secret = cloudflare_access_service_token.token["${each.key}"].client_secret
  })
}

super_cool_hostname.tf
—
resource "cloudflare_access_application" "super_cool_hostname" {
  account_id             	            = var.account_id
  name                   	            = "Super Cool Hostname"
  domain                 	            = "supercool.hostname.tld"
}

resource "cloudflare_access_policy" "super_cool_hostname_service_access" {
  application_id = cloudflare_access_application.super_cool_hostname.id
  zone_id    	= data.cloudflare_zone.hostname_tld.id
  name       	= "TICKET-927 Allow Room Service "
  decision   	= "non_identity"
  precedence 	= 1
  include {
	service_token = [cloudflare_access_service_token.token["room-service"].id]
  }
}

mTLS Certificates Without Expiration Headaches

Authenticated Origin Pulls (AOP) add a layer of defense to the connection between Cloudflare's edge and the origin server. Certificate lifecycle management is one of the most tedious parts of running any internal infrastructure, but encoding those certificates in Terraform removes most of the manual rotation work.

Cloudflare's own implementation uses hostname-level AOP rather than zone-level. Each hostname gets its certificate generated by Vault's PKI backend, typically with a 30-day expiration. The certificate resource is configured to renew automatically, and by setting min_seconds_remaining to a comfortable margin, the old certificate gets replaced on the next Terraform run well before any alerting threshold would fire. The create_before_destroy lifecycle argument guarantees the new certificate uploads successfully before the current one is removed.

locals {
  hostnames = toset([
	"supercool.hostname.tld",
            "thatsafinelooking.hostname.tld"
  ])
}

resource "vault_pki_secret_backend_cert" "vault_cert" {
  for_each          	      = local.hostnames
  backend           	      = "pki-aop"
  name              	      = "default"
  auto_renew         	      = true
  common_name       	      = "${each.key}.aop.pki.vault.cfdata.org"
  min_seconds_remaining = 864000 // renew when there are 10 days left before expiration
}

resource "cloudflare_authenticated_origin_pulls_certificate" "aop_cert" {
  for_each  = local.hostnames
  zone_id   = data.cloudflare_zone.hostname_tld.id
  type 	      = "per-hostname"

  certificate = vault_pki_secret_backend_cert.vault_cert["${each.key}"].certificate
  private_key = vault_pki_secret_backend_cert.vault_cert["${each.key}"].private_key

  lifecycle {
	create_before_destroy = true
  }
}

resource "cloudflare_authenticated_origin_pulls" "aop_config" {
  for_each                           	= local.hostnames
  zone_id    	                        = data.cloudflare_zone.hostname_tld.id
  authenticated_origin_pulls_certificate = cloudflare_authenticated_origin_pulls_certificate.aop_cert["${each.key}"].id
  hostname                           	= "${each.key}"
  enabled                            	= true
}

Practical Advice From Operating the Repository

The automation described above required iterating on how the repository is structured. Several lessons stand out for anyone managing a similar setup with limited engineering hours.

Protect the State File

The tfstate file is not something to leave in the working directory. It contains secrets, including provider API keys, and is extremely easy to commit to version control accidentally. A configured backend moves the state to a secure, encrypted location — object storage, a database, or even Cloudflare Workers. Wherever it lands, encryption should be non-negotiable.

Prefer Explicit Resources Over Modules

Terraform modules work well for genuinely identical, repeated infrastructure chunks. In practice, real systems drift apart as requirements change, and that means migration of conditional logic into module code. HCL conditionals are notoriously difficult to read, and debugging a misconfiguration through a custom module can send users on a long trail. Module versioning creates another maintenance chore for the repository owners.

A few thoughtful for_each loops handle most repetition without the complexity of modules. For resources that need many varying arguments, plain, explicit definitions are often the clearest choice. An account_member resource suits a loop; a page_rule with nuanced configuration likely does not.

Keep State Small Enough to Plan Quickly

A fast pull-request-to-plan cycle keeps Terraform from becoming a time sink. If a plan runs for thirty minutes, a rollback after a failure takes just as long. Cloudflare's general model maps one account to one state file, but when the number of AOP certificate configurations in a large zone started slowing things down, that code moved to a separate state. The change worked because AOP configurations are self-contained, and the API tokens for each state remain mutually exclusive to prevent conflicts. Plans typically finish in under five minutes. If a state file can't stay small enough for reasonable plans, the configuration might be better handled elsewhere.

Recognize Terraform's Limits

Terraform is not the right tool for everything Cloudflare-related. DNS records, for instance, stay under OctoDNS, which integrates more directly with the infrastructure automation and handles records that are frequently generated from external systems. Allowing two systems to publish DNS changes invites conflicts, so only one should own it.

Similarly, Workers scripts do not fit Terraform's model well. Terraform does not detect changes to a .js file when it is referenced in the configuration, so a plan cannot be generated until another .tf file changes. It is a solvable problem, but tools built for Workers, like Wrangler, solve it more naturally than Terraform ever would.