Back to projects
Architectural model for use with Cloudflare® • Terraform • Microsoft CAF aligned

Cloudflare Landing Zone (CFLZ): an opinionated operating model for Terraform

Cloudflare Landing Zones (CFLZ) is a framework for running a multi-account, multi-zone estate as code. Not a collection of Terraform examples - an operating model covering where configuration lives, how state is partitioned, which credential is allowed to touch what, and what a person has to approve before anything changes at the edge.

I designed it coming from an Azure Landing Zone point of view, where the security and governance of a cloud estate is managed through code. What follows is a read-first orientation: the reasoning, the structure, and the guardrails, in the order they matter.

Last updated · Common questions

17
Deployment layers
one Terraform state each
24
Reusable modules
plus one _TEMPLATE
~25.6k
HCL
lines across 266 .tf files
7
Workflows
one can change infrastructure

Counted from the repository when this page was written. The framework is still growing, so treat these as a snapshot and the repository as the current figure.

Why a Cloudflare landing zone

Before any of the structure makes sense, it is worth being precise about what a landing zone is and what its absence costs.

What a Cloudflare landing zone is

A landing zone is the agreed baseline an environment lands in before any workload arrives. Identity, network topology, security posture, naming conventions, and guardrails are decided once, then enforced universally.

Having architected Azure Landing Zones (ALZ) across enterprise clients, I applied the exact same Cloud Adoption Framework principles to Cloudflare: establishing immutable baselines, automated vending, and shift-left policy enforcement.

Why I designed this framework

I designed this framework after auditing dozens of multi-zone estates where minor dashboard edits had silently disabled security controls or caused routing blackholes. I wanted a framework where security baselines are impossible to bypass accidentally, and where onboarding a zone takes minutes rather than days of manual click-ops.

Without one

The dashboard becomes the unversioned source of truth, and each estate ends up slightly different.

  • Environments are assembled by hand in the dashboard, accumulating silent drift between identical zones.
  • Security posture varies per zone, so the weakest configuration sets the actual baseline.
  • Audit blind spots emerge: no verifiable record of who changed a WAF rule, a DNS record, or a Zero Trust policy.
  • Super Administrator roles and unscoped API tokens sprawl beyond any reviewed baseline.
  • Dangerous configurations pass silently because cloud APIs accept combinations they cannot enforce.

With CFLZ

The baseline is codified in Git, peer-reviewed, and inherited by everything that lands after it.

  • A unified, reviewed baseline that every new zone and account inherits automatically upon vending.
  • Platform-wide governance is managed centrally while workload teams retain scoped domain autonomy.
  • Every change arrives via reviewed pull request with a deterministic, recorded plan behind it.
  • Pre-flight assertions catch invalid or dangerous configurations during the plan phase before live apply.
  • Onboarding a domain becomes a clean .tfvars edit rather than hours of dashboard work.

Azure Landing Zone patterns applied to the Cloudflare edge

CFLZ borrows the structure of Azure Landing Zones and the Microsoft Cloud Adoption Framework and applies it to the edge. Three ideas carry the whole design, and every decision further down this page comes back to one of them.

01

GitOps over "click-ops"

Terraform never runs on a workstation - not on an operator laptop and not on a contributor machine. Credentials and state keys live only in GitHub Environments, so the dashboard becomes a read surface and the repository is the source of truth. Every managed resource carries a managed-by tag, so anything created by hand shows up as a diff rather than a surprise.

02

Separation of logic and configuration

The .tf files are customer-agnostic and identical across every tenant. Everything tenant-specific - account IDs, domains, IP ranges, rules - is data in .tfvars. Onboarding a zone is a configuration edit, not a code change.

03

Decoupled state per layer

Each product area is its own root module with its own state file and its own scoped API token. A WAF apply cannot propose destroying a zone, because zones are not in its state and its token cannot edit them.

Two planes, vended one from the other

The platform plane is established once. The application plane is handed out repeatedly, and always inherits what the platform already decided.

Platform landing zone

Account and enterprise governance

One per enterprise account or administrative scope. It owns the account-level foundation, and it owns the vending process: the repeatable way a new domain is onboarded already carrying the security baseline.

  • Account RBAC and audit, keeping Super Administrator grants out of routine changes.
  • Zero Trust identity, device posture, egress filtering, and tunnels.
  • Microsoft Entra ID as the central identity provider.
  • Central Logpush of HTTP, WAF and audit events into a SIEM or R2.

Application landing zone

Zone and workload level

Vended per domain, microservice, or application. Workload teams receive domain autonomy inside immutable platform guardrails rather than an unmonitored blank account.

  • Zone DNS records, pre-validated for collisions, proxy mistakes, and TTL conflicts.
  • Workload WAF: rate limiting, managed ruleset tuning, Bot Management, and API Shield.
  • Cache, transform and origin rules, load balancing, and Authenticated Origin Pulls.
  • Workers with their KV, D1 and queues, and R2 buckets scoped to the zone that owns them.

Cloudflare Terraform repository structure

Three directories, three different jobs. The question this answers: if I am onboarding a customer, which files do I actually touch?

The three-directory model Modules are sourced by layers; account tfvars are passed into layers at run time; only the accounts directory is customer-specific. modules/ - agnostic building blocks modules/ Agnostic building blocks Flat arguments, real IDs zone_base dns_records waf · worker_script · r2_bucket … one per resource type Knows nothing about accounts deployment/layers/ - root modules, one state each deployment/layers/ Root modules - one per product One Terraform state each zones · dns · waf · r2 zerotrust · gateway · tunnels workers · logpush · wan … one per product, all accounts Identical in every tenant repo deployment/accounts/ - the only directory operators edit deployment/accounts/ Configuration as data One tree per Cloudflare account account_a/zones.tfvars account_a/dns.tfvars account_a/waf.tfvars … account_b/… The only files you edit Layers source modules by immutable tag ?ref=v1.2.0 Account tfvars are passed with -var-file at run time -var-file Zones are map keys inside tfvars - never directories. Adding a zone touches three config files, no code.

Zones are map keys inside tfvars, never directories. Adding a zone touches three configuration files and no code.

Upstream template, downstream tenants

The published repository is the upstream template. A production deployment is a fork into the customer's own GitHub organisation, with their own R2 state bucket and their own scoped tokens. Modules live alongside the layers for development convenience, but a real tenant points each layer at a tagged external module repository - so a commit on main cannot reach live infrastructure until someone bumps a version deliberately.

Configuration precedence

  1. layers/<layer>/defaults.auto.tfvars - the platform baseline, auto-loaded and customer-agnostic.
  2. accounts/<account>/*.tfvars - passed with -var-file, overriding the defaults.
  3. local.auto.tfvars - operator experiments, gitignored.

The same idea in four files

Following one DNS record from configuration through to the resource. Nothing customer-specific appears outside the first two files.

config deployment/accounts/account_a/zones.tfvars Identity only: a logical key mapped to a domain. Passed to every layer.
HCL
# Identity only: logical key => domain name. Passed to EVERY layer, so you only
# reference "primary" instead of your domain. Keys are permanent - renaming one
# destroys and recreates the zone.

zones = {
  primary = {
    domain_name = "example.com"
    zone_tier   = "business"
  }

  mail = {
    domain_name = "example.org"
    # No zone_tier, so this zone falls back to default_zone_tier ("free").
  }
}
config deployment/accounts/account_a/dns.tfvars Records keyed by the same logical keys. Nothing here names a zone ID.
HCL
# Consumed by the dns layer only. Keys must match zones.tfvars.
#
# Reminder: proxied = true needs ttl = 1 and only works on A/AAAA/CNAME.

dns_config = {
  primary = {
    dns_records = [
      { name = "@",   type = "A",     content = "203.0.113.10",   ttl = 1,    proxied = true },
      { name = "www", type = "CNAME", content = "example.com",    ttl = 1,    proxied = true },
      { name = "@",   type = "MX",    content = "mx.example.net", ttl = 3600, priority = 10 },
      { name = "@",   type = "TXT",   content = "v=spf1 include:example.net -all", ttl = 3600 },
    ]
  }
}
layer deployment/layers/dns/zone_lookup.tf and dns.tf Resolves the zone by name at plan time, then fans out over the module.
HCL
# zone_lookup.tf - zone key to zone ID, without reading the zones layer's state.
data "cloudflare_zone" "this" {
  for_each = local.zones_with_records

  filter = {
    name    = each.value.domain_name
    account = { id = var.cloudflare_account_id }
  }
}

# dns.tf - downstream deployments pin an immutable tag instead of a local path.
module "dns" {
  source = "git::https://github.com/Your-Org/cloudflare-platform-modules//dns_records?ref=v1.4.0"

  for_each = local.zones_with_records

  zone_id     = data.cloudflare_zone.this[each.key].zone_id
  domain_name = each.value.domain_name

  records = each.value.dns_records
}
module modules/dns_records/main.tf Takes a zone_id rather than a domain, so it can never own the zone.
HCL
# DNS records for a zone this module does not own.
#
# Splitting DNS into its own layer and state means a DNS change refreshes DNS
# and nothing else, and a zone-settings change refreshes settings and nothing
# else. This is why this module takes zone_id rather than a domain to create.
resource "cloudflare_dns_record" "this" {
  for_each = local.records

  zone_id  = var.zone_id
  name     = each.value.name
  type     = each.value.type
  content  = each.value.content
  ttl      = each.value.ttl
  proxied  = each.value.proxied
  priority = each.value.priority

  lifecycle {
    precondition {
      condition     = length(local.duplicate_keys) == 0
      error_message = "records contains duplicate type/name/content combinations. Names are compared fully-qualified, so \"www\" and \"www.<domain>\" collide - each record must be unique."
    }
  }
}

Terraform state partitioning and blast radius

The question this answers: what is the worst a single bad apply can do? Here the answer matters more than on most platforms, because deleting a zone takes every DNS record in it.

Monolithic state versus state per account and layer On the left one state holds every product, so any apply can reach the zones. On the right each account-layer pair has its own state and token, containing failure to one cell. What CFLZ avoids: a single state for the whole account Single state (avoided) zones dns waf r2 · workers · … one token, one plan a routine WAF change can propose destroying the zone it reads CFLZ: one state key and one scoped token per account and layer pair CFLZ: state per account × layer account_a/ zones dns waf r2 … account_b/ zones dns waf r2 … failure stops here its token holds Zone WAF:Edit + Zone:Read, so it cannot edit DNS or the zone at all one state key per account per layer, in R2 State lives in Cloudflare R2 via the S3-compatible backend, one key per account per layer, locked with use_lockfile = true.

State lives in R2 through the S3-compatible backend, one key per account per layer, locked with conditional writes.

No cross-state reads

Consumer layers never use terraform_remote_state. They resolve a zone key to a zone ID with a data source filtered by name and account. States stay fully decoupled, so any layer can be re-initialised or migrated alone. The trade is that consumer layers need the API reachable at plan time.

Locking without a lock table

R2 has no DynamoDB equivalent, so concurrent applies on one key could corrupt it. use_lockfile = true uses S3 conditional writes, which R2 supports. That is why every layer pins required_version >= 1.11.0: on older Terraform the argument is not understood, and the failure mode is an unlocked apply rather than an error. Pipeline concurrency groups serialise per state key as a second defence.

Backend committed commented out

The backend "s3" block ships commented out so CI can run terraform init -backend=false offline with no credentials. The runner injects bucket, key and endpoint at init time, so nothing account-specific is ever committed.

Layers, and what each one owns

A representative selection rather than a full inventory - layers are added as the framework grows, so the repository is the current list. Tier is derived from the Terraform source rather than from directory names, which is the subject of the next section. Token scope is the minimum that layer's apply environment holds.

Example deployment layers, each with its apply tier, its scope, what it owns, and the token its apply environment holds
Layer Tier Scope Owns Apply token
account_governance 1 Account Members, user groups, RBAC, resource groups Account Settings:Edit
zones 2 Zone Zone lifecycle, rate plan, TLS posture, settings, Bot Management Zone:Edit, DNS:Edit, Zone Settings:Edit
bulk_redirects 2 Account URL redirect lists and the execution ruleset Account Filter Lists:Edit, Account Rulesets:Edit
device_posture 2 Account Posture checks, MDM and EDR integrations Zero Trust:Edit
gateway 2 Account Secure web gateway DNS, network and HTTP policies, TLS inspection Zero Trust:Edit
lists 2 Account IP, ASN and hostname lists consumed by WAF Account Filter Lists:Edit
wan 2 Account IPsec and GRE tunnels, static routes Magic Transit:Edit
dns 3 Zone Per-zone DNS records, split from zone lifecycle DNS:Edit, Zone:Read
waf 3 Zone Custom firewall rules, rate limiting Zone WAF:Edit, Zone:Read
rules 3 Zone Cache, transform and origin rules Zone ruleset phases, Zone:Read
load_balancing 3 Zone Health monitors, origin pools, failover Load Balancers:Edit, Zone:Read
origin_pulls 3 Zone Authenticated Origin Pulls, edge-to-origin mTLS per hostname SSL and Certificates:Edit, Zone:Read
tunnels 3 Both Tunnels, public hostnames, private network routes Tunnel:Edit, DNS:Edit when publishing
workers 3 Both Worker scripts, routes, crons, KV, D1, queues Workers Scripts and KV:Edit, plus what is declared
zerotrust 3 Account Access applications and policies, service tokens, identity providers Access apps, policies, IdPs and service tokens:Edit
r2 3 Both Buckets, CORS, lifecycle, retention, custom domains R2 Storage:Edit, plus Zone:Read and DNS:Edit for domains
logpush 3 Both Logpush jobs for audit, Gateway and zone logs Logs:Edit, Zone:Read, PII Read where needed

Execution order is derived, not declared

Layer directories carry no numeric prefixes. Order comes from real infrastructure dependencies, read out of the Terraform source by the matrix script. The question this answers: why does adding a layer not require editing the pipeline?

Three sequential apply tiers Tier 1 is account governance, tier 2 creates zones and account-scoped services, tier 3 holds every layer that resolves a zone by data source. Membership is derived from the source code. Tier 1 - account authorisation, applied alone and first Tier 1 · Platform authorisation account_governance RBAC and resource-group scope every later token is judged against derived from: nothing - PREREQ_LAYERS list applied, not merely planned, before the next tier starts Tier 2 - zone creation and account-scoped services Tier 2 · Foundational zones & account services zones bulk_redirects device_posture gateway lists wan only zones creates zones; the rest simply never need one derived from: layer calls the zone_base module (exactly one may) tier 3 plans fail until the zone exists at Cloudflare Tier 3 - layers that resolve a zone at plan time Tier 3 · Zone-dependent & consumer layers dns waf rules load_balancing logpush r2 tunnels workers origin_pulls zerotrust derived from: layer contains data "cloudflare_zone" except zerotrust, forced here by POST_ZONE_LAYERS Everything inside one tier runs concurrently. ci.yml asserts all three derivations, so a regression fails a PR rather than silently skipping a layer.

Everything inside one tier runs concurrently. CI asserts all three derivations, so a regression fails a pull request rather than silently skipping a layer.

Why Zero Trust is the one exception

Access applications are addressed by hostname, so the zone and its DNS record have to exist first. But that layer never reads a zone data source, so nothing in the Terraform expresses the dependency. It is therefore listed explicitly, and the matrix script refuses to start if that list names a directory that does not exist, or a layer that also creates zones.

What the derivation produces

One job per account and layer pair, the same set grouped by layer, the apply order as a list of tiers, and per-tier subsets with a flag saying whether that tier has any work. On a pull request it narrows by changed files: touching a shared module selects every layer that calls it, for every account.

Edge change management in CI/CD

Terraform runs inside GitHub Actions and nowhere else - not on an operator laptop and not on a contributor workstation. The question this answers: which credential is in play at each step, and where does a person stand in the way?

Plan and apply flow with credential boundaries A manual dispatch expands into account-layer pairs, plans with a read-only token, saves an immutable plan artifact, waits for a reviewer in a per-layer environment, then applies that exact plan with a scoped write token, followed by a tagging job with a tag-only token. STEP CREDENTIAL IN PLAY workflow_dispatch - manual only workflow_dispatch Actions tab. Inputs: account, layer, api_rps, parallelism. No push trigger. tf-matrix.sh and tf-varfiles.sh resolve the work tf-matrix.sh → tf-varfiles.sh Selects every {account, layer} pair the inputs allow and sorts them into 3 tiers; resolves the exact -var-file arguments each layer declares. init with backend config injected at run time terraform init -reconfigure Bucket, key and endpoint injected as -backend-config. Modules pulled with a short-lived GitHub App token. R2 state keys (repo secrets) Object Read & Write, state bucket only Loopback rate limiter in front of the Cloudflare API cf-api-throttle.py → CLOUDFLARE_BASE_URL Token bucket at 3.5 rps (the limit is 1200 / 5 min = 4.0/s) and retries any 429, honouring Retry-After. Binds 127.0.0.1 only. Provider 5.x paces nothing itself. Plan runs with a read-only token terraform plan -out=plan.tfplan Saved as an immutable artifact. A value-free summary of resource addresses and actions goes to the job summary. <account>-plan Read-only token, account + zone scope. No reviewer; runs from any branch. Human approval gate PAUSE · a required reviewer approves that specific plan No -auto-approve anywhere in the repository. No destroy path anywhere either. Apply consumes the exact approved plan file terraform apply plan.tfplan Terraform refuses a saved plan whose state has moved, so an apply cannot quietly diverge from what was reviewed. <account>-<layer>-apply That one layer's write token. Reviewer required; main branch only. Post-apply tagging job resource-tags.sh Runs once per account after the last tier; writes only the tags that differ. <account>-tags-apply Tag-only token. No reviewer, main only.

Apply consumes a recorded plan file produced earlier in the same run, so the change a reviewer approved is the exact change that executes. There is no auto-approve anywhere, and no destroy path.

The workflows, and what each is allowed to do

Only one of them can change infrastructure, and only after a required reviewer has approved a specific plan.

Each GitHub Actions workflow, what triggers it, and what it is allowed to touch
Workflow Trigger Touches Cloudflare Can change anything What it is for
ci.yml Every pull request and push to main No No fmt -check, tflint, validate per layer, and an offline plan for layers that can be planned with no credential.
secret-scanning.yml Every pull request, push, manual No No Credential leak detection across the working tree.
terraform-plan.yml Manual only Reads No Fleet-wide or narrowed plan. Publishes the plan artifact and a value-free summary.
terraform-apply.yml Manual only Reads and writes Yes, after approval Plans, waits for a required reviewer, applies that exact plan file, then reconciles tags.
state-forget.yml Manual only No State only Hands a resource from one layer state to another without deleting it at the edge.
state-unlock.yml Manual only No The lock only Releases an R2 lockfile left behind by a cancelled run.
_terraform-run.yml Reusable, called by the above Per caller Per caller The shared init, throttle, plan and apply body every run goes through.

Rate limiting is load-bearing, not a nicety

The Terraform provider paced itself in 4.x through explicit requests-per-second, retry and backoff arguments. The 5.x rewrite dropped all of them and never replaced them, so a 5.x provider has no rate limiting and no retry on 429. At a few hundred zones a single plan is thousands of reads, and unpaced it dies partway through. A loopback token bucket sits in front of the API at 3.5 requests per second, against a documented ceiling of 1200 per five minutes, and honours Retry-After. Plans on the zones layer are therefore slow by design: a refresh of n resources cannot finish faster than n divided by the rate.

Tagging, until the provider catches up

The Tagging API is in public beta and the provider has no tagging resource, so Terraform decides the tags and a script writes them. Four layers validate their tag configuration at plan time and output the full tag set with IDs; the apply job uploads that as a manifest, and a separate tag-only job reconciles it. Every managed resource carries managed-by and layer, which tenant configuration cannot override - so filtering for anything without managed-by finds what was created by hand. DNS records are deliberately untagged, because the beta caps an account at ten thousand tags.

Credential isolation and least privilege

The access model is the architecture. One GitHub Environment per account per layer means a credential that can rewrite WAF rules is physically unable to touch DNS, and only the governance layer can grant administrative access.

<account>-plan

One per account. A read-only token plus any TF_VAR_* secrets a plan needs. No reviewer and any branch, because a plan cannot change anything.

<account>-<layer>-apply

One per layer, holding only the write token for that one layer, behind a required reviewer and restricted to main. Created by the bootstrap script rather than by hand.

<account>-tags-apply

Tag-only token. No reviewer by design: it writes only what an already-approved apply produced, and has no plan of its own to show. Still restricted to main.

What is committed, and what never is

Account IDs, domain names, IP ranges and routing topologies live in .tfvars. That is operational configuration rather than secret material, protected by private repository access control. The ignore file is default-deny for *.tfvars with explicit allow-listing, and it re-denies the three filenames Terraform auto-loads.

Secrets arrive only as environment variables: the API token, the R2 state keys, and a handful of TF_VAR_* maps - identity provider client secrets, IPsec pre-shared keys, Logpush destinations, device posture integration secrets, and origin-pull private keys.

Private modules without a personal token

The built-in Actions token is scoped to one repository, so terraform init against a private modules repository would fall through to interactive git authentication. A composite action mints a GitHub App installation token per run: roughly one hour, read-only, scoped to that one repository, and revoked in a post step. Nothing is tied to a leaver and nothing expires silently. It even shape-checks the private key, because a malformed one otherwise fails deep inside the runtime crypto with no hint that the secret is the problem.

Edge security baseline: guardrails that fail before the apply

A baseline is only a baseline if it cannot be quietly broken. Native HCL validation and precondition blocks run during terraform plan, so a dangerous change stops before anything reaches production. Several of these catch configurations the API accepts without complaint and then ignores. Open one to see the assertion behind it.

Critical Security

Super Administrator Lockout Prevention

The account governance layer refuses to assign the Super Administrator role unless explicitly unlocked in code.

How it is enforced
HCL Precondition validation on role assignments.
Why it matters
Protects the root access model from accidental privilege grants in automated pipelines.
Pre-flight assertion, HCL
lifecycle {
  precondition {
    condition     = var.allow_super_admin_assignment || !contains(var.role_names, "Super Administrator")
    error_message = "CFLZ Guardrail: Assigning 'Super Administrator' role is locked by default. Set allow_super_admin_assignment = true to authorise."
  }
}
Critical Security

WAF Admin Lockout Prevention

Selecting block_admin_from_untrusted without providing an explicit trusted IP list immediately fails plan execution.

How it is enforced
Variable validation & assertion blocks in WAF orchestration layers.
Why it matters
Guarantees administrators cannot inadvertently blackhole their own administrative access when publishing strict WAF rulesets.
Pre-flight assertion, HCL
lifecycle {
  precondition {
    condition     = !var.block_admin_from_untrusted || length(var.trusted_admin_ip_list) > 0
    error_message = "CFLZ Guardrail: block_admin_from_untrusted requires non-empty trusted_admin_ip_list to prevent lockout."
  }
}
High Storage

R2 Bucket Public Exposure Control

A bucket requesting an anonymous r2.dev URL or an unconstrained wildcard CORS origin fails the plan immediately.

How it is enforced
Bucket policy analysis during pre-flight evaluation.
Why it matters
Prevents accidental data leaks where internal object storage is exposed to the public internet via default dashboard options.
Pre-flight assertion, HCL
lifecycle {
  precondition {
    condition     = !var.enable_r2_dev_url && !contains(var.cors_allowed_origins, "*")
    error_message = "CFLZ Guardrail: Public r2.dev domain and wildcard CORS origins (*) are prohibited on enterprise buckets."
  }
}
Critical Networking

Cloudflare WAN Next-Hop Derivation

Static routes derive their next-hop address directly from the IPsec tunnel interface rather than manual text input.

How it is enforced
Dynamic /31 CIDR subnet derivation in Magic WAN module.
Why it matters
Manual next-hop inputs on /31 subnets easily cause off-by-one errors where traffic is accepted by the API as healthy but silently blackholed.
Pre-flight assertion, HCL
locals {
  derived_next_hop = cidrhost(cloudflare_ipsec_tunnel.primary.interface_address, 1)
}
High Platform

Gateway DLP Match Logging Protection

Storing matched payload content for Data Loss Prevention (DLP) requires explicit, separate cryptographic authorisation.

How it is enforced
Explicit Boolean gate and audit logging flags.
Why it matters
DLP matches contain sensitive enterprise secrets (e.g. credit cards, PII, API tokens). Logging payloads exposes that data to all log readers.
Pre-flight assertion, HCL
lifecycle {
  precondition {
    condition     = !var.log_dlp_payloads || var.authorise_sensitive_payload_capture
    error_message = "CFLZ Guardrail: Logging raw DLP matches captures sensitive PII/secrets. Explicit authorisation required."
  }
}

Other safety guardrails

Zero Trust Bypass Restriction

Access policies using decision = "bypass" are rejected by default, preventing accidental exposure of protected endpoints.

Team Domain Protection

Renaming a Zero Trust team domain requires explicit confirmation, since it breaks Access URLs and forces WARP re-enrolment.

Billing Change Visibility

Rate plans never change unless subscription management is enabled, and any such run names every zone and its target plan. An accidental downgrade strips WAF, rate limiting, and Bot Management.

R2 Irreversible Deletion Guard

Bucket-wide expiry lifecycle rules need an explicit unlock, because R2 has no versioning. A lifecycle rule colliding with object lock also fails, as Cloudflare accepts that pair then silently refuses deletion.

Gateway Inspection Bypass Control

A Do Not Inspect policy matching on post-decryption signals never fires, and Cloudflare reports no error. Those combinations, and unscoped catch-all allows, fail the plan.

Gateway Rule Ordering

Precedence is explicit and unique per policy type, with the band below 100 reserved for the platform baseline so tenants cannot jump ahead of a platform block.

Cloudflare WAN Secret Handling

IPsec pre-shared keys are sensitive variables supplied by the pipeline, never committed. A key naming a non-existent tunnel fails, so a missed rotation cannot pass unnoticed.

Correctness checks

DNS Validation

Catches record collisions, proxied TXT records, and proxied records carrying explicit TTLs before submission.

Load Balancer Logic

Verifies pool health-check timeouts are shorter than intervals, and that minimum healthy origin counts do not exceed pool capacity.

Identity Integrity

Validates that user groups do not assign permissions to undeclared members.

Plan Tier Gating

Each zone declares its Cloudflare tier. A setting the tier cannot support fails the plan naming the field and the plan it needs.

Bot Rule Placement

Per-category bot rules land in the single ruleset Cloudflare allows per phase per zone, ahead of baseline and tenant rules, so an allow actually takes effect.

Where CFLZ departs from the published guidance

Cloudflare publishes Terraform best-practice guidance that recommends organising by account, zone and product directories, and advises teams to avoid modules or use them sparingly. CFLZ deliberately does neither, which is worth understanding before judging the layout.

Published guidance against the approach CFLZ takes, and the reasoning
Question Published guidance CFLZ, and why
Modules Avoid them, or use them sparingly. Shared monolithic modules hide security behaviour behind ternaries and unpinned local paths, and an internal change can unexpectedly recreate dependent resources. Use them, but one repository per module with immutable SemVer tags. A breaking input change cannot reach a tenant without an explicit tag bump, and each product area gets its own commit history for audit.
Zone layout One directory per zone, each with its own duplicated .tf files. Code lives once per product layer, and zones are for_each entries in tfvars. At fifty or more zones the directory model means a provider bump edits hundreds of files.
State Implicitly one state per zone directory. One state per account and layer pair. Zone deletion, which is the most destructive failure mode on this platform, is isolated to the zones layer alone.
Cross-layer references Not addressed. Never terraform_remote_state, always a plan-time data source lookup. No inter-state coupling, no pipeline locking, and layers migrate independently.
Multiple accounts Not addressed. One run per account, never a for_each over accounts. A provider block carries one token, and Terraform cannot take a dynamic provider alias in a for_each module. That constraint is turned into the isolation guarantee.

Managing Cloudflare at scale, day to day

What the day-to-day actually looks like, and what changes once more than one customer is involved.

Adding a zone, configuration only

  1. Branch. Never commit to main.
  2. Add the logical key and its domain to accounts/<account>/zones.tfvars.
  3. Add its settings to zone_config.tfvars and its records to dns.tfvars, under the same key.
  4. Open a pull request. CI narrows to the affected layers and runs format, lint, validate and an offline plan.
  5. Merge, then dispatch the apply workflow. Tier 2 creates the zone, tier 3 fills it.

Logical keys are permanent identity. Renaming one destroys and recreates the resource. Keys are scoped per account, so primary in one account is unrelated to primary in another.

Running this for more than one customer

A single repository works for an isolated enterprise estate. Managing several clients or business units needs stricter isolation, so each customer gets their own repository, their own R2 state bucket, and their own scoped tokens. Hosting multiple clients in one repository would share pipeline permissions and state keys, exposing every tenant to a single operator error.

Production environments reference tagged, external module releases. Changes on main cannot alter live client infrastructure until an operator explicitly tests and bumps the version in their own configuration.

layer deployment/layers/zones/zone_base.tf A pinned tag is the boundary between upstream development and a live tenant.
HCL
module "zone_governance" {
  source = "git::https://github.com/itsharryshelton/cloudflare-lz-modules.git//modules/zone_base?ref=v1.4.0"

  zone_id            = var.zone_id
  security_level     = var.security_level
  tls_1_3_mode       = "on"
  min_tls_version    = "1.2"
  always_use_https   = true
  waf_profile        = var.waf_profile
  managed_transforms = true
}

Bootstrapping with the Release Manager

To streamline onboarding I built Release Manager. It creates the GitHub repository, scaffolds the environments and scoped API secrets described above, and wires up the workflows, so a team can stand up a production-ready landing zone in minutes rather than assembling the access model by hand.

Common questions

The questions that come up most often about landing zones on Cloudflare, answered in the same terms the rest of this page uses.

What is a Cloudflare landing zone?

A Cloudflare landing zone is the agreed baseline an environment lands in before any workload arrives. Account RBAC, Zero Trust identity, WAF posture, DNS conventions and guardrails are decided once in Terraform, then inherited by every account and zone vended afterwards, rather than being assembled by hand in the dashboard.

What is the Cloudflare equivalent of an Azure Landing Zone?

There is no first-party equivalent, which is why CFLZ exists. It takes the Microsoft Cloud Adoption Framework hierarchy behind Azure Landing Zones - a platform plane for governance, application planes vended from it, and policy enforced before deployment - and applies the same separation to Cloudflare accounts and zones.

How do you manage Cloudflare across multiple accounts with Terraform?

Keep the Terraform customer-agnostic and identical across tenants, and move everything account-specific into .tfvars passed with -var-file at run time. Each customer gets their own repository, their own R2 state backend and their own scoped tokens, so onboarding an account is a configuration edit rather than a code change.

How should Terraform state be partitioned for a Cloudflare estate?

One state file per account and per layer, with no cross-state reads. A WAF apply cannot propose destroying a zone, because zones are not in its state and its token cannot edit them. That matters more on Cloudflare than on most platforms, because deleting a zone takes every DNS record in it.

How should a Cloudflare Terraform repository be structured?

Three directories with three different jobs: agnostic modules as building blocks, deployment layers as root modules holding one state each, and account directories holding the only files an operator edits. Cloudflare publishes guidance recommending account, zone and product directories with modules used sparingly; CFLZ deliberately does neither, for reasons set out on this page.

Can Terraform catch a dangerous Cloudflare change before it is applied?

Yes. Native HCL validation and precondition blocks run during terraform plan, so a dangerous change fails before anything reaches the edge. Several of these catch configurations the Cloudflare API accepts without complaint and then silently ignores, which no amount of post-apply monitoring would surface.

How do you run change management for Cloudflare through CI/CD?

Terraform runs inside GitHub Actions and nowhere else, never on an operator laptop. Plans run with a read-only token, a person approves at a GitHub Environment gate, and the apply consumes the exact plan file that was approved using a write token scoped to that one layer.

Questions about CFLZ or edge architecture?

Happy to spar on edge landing zones, discuss mapping this pattern onto your existing estate, or collaborate on open-source modules.

harry@harryshelton.com