How to Manage Cloudflare with Terraform
A technical, end-to-end workflow for putting an existing Cloudflare zone under Terraform: provider setup, DNS as code, redirects, a Pages project, zone settings, bot management, DNSSEC, and a working Email Routing forward.
Stop: Heavily Technical Guide
This walkthrough assumes comfort with Terraform, Git, and reading a Cloudflare zone's existing DNS and security settings before changing them.
Quick Path
- Run the bootstrap script first (installs Terraform + jq and scaffolds a starter project, including a commented 4leggedit.com example).
- Create a scoped Cloudflare API token (Zone Read, DNS Edit, Zone Settings Edit, Dynamic Redirect Edit, Email Routing Rules Edit, Account: Cloudflare Pages Edit).
- Configure the Cloudflare provider — versions.tf, providers.tf, and variables.tf are already scaffolded by Step 0.
- Find your Zone ID and import already-existing resources instead of letting Terraform try to recreate them.
- Declare the www CNAME in a map and apply it with one for_each resource.
- Add a redirect ruleset for the apex domain, then connect a Cloudflare Pages project (GitHub access is a one-time manual step).
- Apply your zone settings baseline (SSL/TLS + HSTS), then bot management, then DNSSEC.
- Turn on Email Routing and add a forwarding rule — the destination inbox has to accept a verification email before it actually works.
- Add the SPF and DMARC records the Email Routing example needs to that same DNS map.
- Always run terraform plan before terraform apply, especially for DNS, redirects, and Email Routing changes.
Security & Privacy
- Never commit terraform.tfvars or any *.tfstate* file — both can hold your API token or zone details. Keep them out of version control with .gitignore.
- Scope your API token to only what this workflow needs — never use your account's Global API Key.
- Read every terraform plan output before you apply, especially when it touches DNS, redirects, or Email Routing — a wrong diff there can break mail delivery or take a site offline.
Step 0 — Quick bootstrap (recommended first run)
This is the fastest way to get a Mac ready for Terraform and Cloudflare work before the detailed manual walkthrough below.
The script installs Xcode Command Line Tools and Homebrew if needed, then Terraform and jq, and scaffolds a starter project folder with every file this guide uses — including a locals.tf with commented examples using 4leggedit.com as the reference zone.
- Download the bootstrap script.
- Run it from Terminal.
- If it creates a new cloudflare-terraform folder, change into it before continuing.
- Continue with Step 1 to create a scoped API token.
Expand to copy: Download scriptCopy exactly as shown. Do not modify.
curl -fsSL https://www.4leggedit.com/downloads/setup-cloudflare-terraform.sh -o setup-cloudflare-terraform.shExpand to copy: Run scriptCopy exactly as shown. Do not modify.
chmod +x setup-cloudflare-terraform.sh
./setup-cloudflare-terraform.shterraform -version reports a Terraform CLI version with no errors.
A cloudflare-terraform folder exists with every .tf file this guide uses, enable-dmarc-management.sh (Step 11), terraform.tfvars.example, and .gitignore already in place — running terraform plan there reports no changes, since everything domain-specific is still commented out or empty.
Step 1 — Create a scoped Cloudflare API token
Terraform authenticates to Cloudflare with an API token, not your account password or the account-wide Global API Key.
Scoping the token to only the permissions this workflow needs limits the damage if it ever leaks. Grant these exact permissions in the token's permission picker — the left dropdown on each row picks Zone or Account, the middle one is the permission name, the right one is the access level:
Zone Resources, scoped to your zone — Zone: Read, DNS: Edit, Zone Settings: Edit, Dynamic Redirect: Edit, Email Routing Rules: Edit.
Account Resources, scoped to your account — Cloudflare Pages: Edit (only if you'll follow Step 6's Pages project), Email Security: Edit (only if you'll follow Step 11's DMARC Management script — note this is the single combined "Email Security" permission, not a separate DMARC-specific one).
- This guide assumes your domain is already on Cloudflare — an active zone with nameservers delegated. If it isn't yet, you have three options: register a new domain directly with Cloudflare, transfer an existing domain's registration, or just move your DNS to Cloudflare without transferring anything.
- In the Cloudflare dashboard, go to My Profile, then API Tokens, then Create Token.
- Choose Create Custom Token and grant every permission listed above — the Zone Resources ones for your zone, the Account Resources ones for your account.
- Scope the token to the specific zone and account (for example 4leggedit.com), not All Zones, unless you're managing several.
- Export the token as an environment variable rather than pasting it into any file Terraform will read from disk.
- Verify the token works before wiring it into Terraform.
Expand to copy: Verify tokenCopy exactly as shown. Do not modify.
curl -s -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json"The verification request returns an active status for the token.
You have not written the token into any file that could be committed to Git.
Step 2 — Configure the Terraform provider
This tells Terraform which provider to use, which version to pin, and where to get the API token from.
Pinning the provider version keeps a later Cloudflare provider release from silently changing behavior under you.
Note: if you ran the Step 0 bootstrap script, versions.tf, providers.tf, variables.tf, and every other .tf file this guide uses already exist in your cloudflare-terraform folder — this step is about understanding what's in them, not recreating them. locals.tf already has commented examples using 4leggedit.com as the reference zone; the following steps build on it.
- If Step 0 already created these files, open them and compare against the examples below instead of recreating them.
- Otherwise, create versions.tf to pin the Terraform and Cloudflare provider versions.
- Create providers.tf to configure the Cloudflare provider with your token.
- Create variables.tf to declare the token as a sensitive input variable.
- Copy terraform.tfvars.example to terraform.tfvars and fill in your token locally — never commit this file.
Expand to copy: versions.tfCopy exactly as shown. Do not modify.
terraform {
required_version = ">= 1.5.0"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.0"
}
}
}Expand to copy: providers.tfCopy exactly as shown. Do not modify.
provider "cloudflare" {
api_token = var.cloudflare_api_token
}Expand to copy: variables.tfCopy exactly as shown. Do not modify.
variable "cloudflare_api_token" {
description = "Cloudflare API token (Zone Read, DNS Edit, Zone Settings Edit, Email Routing Rules Edit)"
type = string
sensitive = true
}terraform init completes without errors and reports the Cloudflare provider was installed.
terraform.tfvars exists locally, is listed in .gitignore, and never shows up in git status.
Step 3 — Find your Zone ID and import what already exists
Most Cloudflare zones aren't brand new — they already have DNS records, TLS settings, and possibly DNSSEC turned on from before Terraform entered the picture.
Importing those existing resources into Terraform's state first means your first terraform plan shows the differences you actually intend to make, instead of Terraform proposing to recreate everything that's already there.
- Find the Zone ID on the zone's Overview page in the Cloudflare dashboard, in the API section of the right-hand sidebar.
- Uncomment the example zone entry in locals.tf (scaffolded by Step 0) and replace 4leggedit.com and the placeholder zone_id with your own.
- Write the resource blocks for anything you want to manage (see the following steps), then import each one by its Cloudflare-assigned ID before your first apply.
- Run terraform plan after each import and confirm it reports no changes for that resource.
Expand to copy: locals.tf zone registryCopy exactly as shown. Do not modify.
locals {
# Add one entry per zone you want Terraform to manage.
#
# Example (matches the walkthrough in this guide) — uncomment and replace
# with your own domain and zone_id:
#
# zones = {
# "4leggedit.com" = { zone_id = "<your-zone-id>" }
# }
zones = {
}
}Expand to copy: Import a DNS recordCopy exactly as shown. Do not modify.
terraform import 'cloudflare_dns_record.records["4leggedit.com|CNAME|www"]' <zone_id>/<dns_record_id>Expand to copy: Import DNSSEC statusCopy exactly as shown. Do not modify.
terraform import 'cloudflare_zone_dnssec.this["4leggedit.com"]' <zone_id>terraform plan shows zero changes for every resource you've already imported.
Your zone_id is only ever read from locals.tf, never hardcoded again elsewhere.
Step 4 — Manage DNS records as code
Declaring DNS records as a map and applying them with a single for_each resource keeps every record in one reviewable place instead of scattered dashboard edits nobody remembers making.
This example manages the www CNAME for www.4leggedit.com — the same pattern extends to any A, AAAA, CNAME, TXT, or MX record.
- Add the record to your dns_records map in locals.tf.
- dns.tf (created by Step 0) already has the resource that loops over that map — nothing to add there.
- Run terraform plan and review the diff before applying.
- Apply, then confirm the record resolves as expected.
Expand to copy: locals.tf DNS recordCopy exactly as shown. Do not modify.
locals {
dns_records = {
"4leggedit.com|CNAME|www" = {
domain = "4leggedit.com"
name = "www.4leggedit.com"
type = "CNAME"
content = "4leggedit.com"
proxied = true
ttl = 1
}
}
}Expand to copy: dns.tfCopy exactly as shown. Do not modify.
resource "cloudflare_dns_record" "records" {
for_each = local.dns_records
zone_id = local.zones[each.value.domain].zone_id
name = each.value.name
type = each.value.type
content = each.value.content
proxied = each.value.proxied
ttl = each.value.ttl
}terraform plan shows the expected create or update for exactly the record you changed, and nothing else.
A DNS lookup for www.4leggedit.com returns the value you declared.
Step 5 — Apply redirects (page rewrite rules)
A redirect ruleset keeps canonical-URL rules — like sending the bare apex domain to www — in the same reviewable place as everything else, instead of a one-off rule someone configured by hand in the dashboard and forgot about.
This example redirects 4leggedit.com to https://www.4leggedit.com, preserving the request path and query string.
- Uncomment the cloudflare_ruleset resource in redirects.tf (created by Step 0) and adapt it to your domain.
- Run terraform plan and review — a redirect ruleset affects every request to the matched hostname immediately on apply.
- Apply, then confirm the apex domain redirects to www.
Expand to copy: redirects.tfCopy exactly as shown. Do not modify.
resource "cloudflare_ruleset" "redirects" {
zone_id = local.zones["4leggedit.com"].zone_id
name = "Canonical redirects"
description = "Redirect the apex domain to www"
kind = "zone"
phase = "http_request_dynamic_redirect"
rules = [
{
description = "apex to www"
expression = "(http.host eq \"4leggedit.com\")"
action = "redirect"
action_parameters = {
from_value = {
status_code = 301
target_url = {
expression = "concat(\"https://www.4leggedit.com\", http.request.uri.path)"
}
preserve_query_string = true
}
}
}
]
}terraform plan shows the expected ruleset create or update.
Visiting 4leggedit.com in a browser redirects to https://www.4leggedit.com.
Step 6 — Connect a Cloudflare Pages project to GitHub
This is the one step in this guide with a manual prerequisite: Cloudflare's GitHub integration for Pages is authorized once per account through the dashboard's own OAuth flow, and no Terraform resource can grant that access for you.
Once the GitHub App is installed and your repository is authorized, Terraform can manage the Pages project itself — which repo and branch it builds from, the build command, and the custom domain attached to it.
- For the full click-by-click walkthrough of connecting GitHub — including from a Lovable-originated project — and authorizing Cloudflare's GitHub App, see Publish Your Website with Cloudflare Pages + GitHub. This step only covers the Terraform side once that access exists.
- In the Cloudflare dashboard, go to Workers & Pages, then Create, then Pages, then Connect to Git, choose GitHub, and authorize the Cloudflare Pages GitHub App for the repository you want to deploy — this one-time authorization can't be done through Terraform.
- Stop before finishing the dashboard's own project setup wizard — you'll create the project with Terraform instead, using the access you just granted.
- Uncomment pages.tf (created by Step 0): the cloudflare_account_id variable, the cloudflare_pages_project resource, and the cloudflare_pages_domain resource.
- Run terraform plan and review, then apply.
Expand to copy: variables.tf: account idCopy exactly as shown. Do not modify.
variable "cloudflare_account_id" {
description = "Cloudflare account ID (found in the dashboard sidebar)"
type = string
}Expand to copy: pages.tfCopy exactly as shown. Do not modify.
resource "cloudflare_pages_project" "site" {
account_id = var.cloudflare_account_id
name = "4leggedit-site"
production_branch = "main"
source = {
type = "github"
config = {
owner = "4LeggedIT"
repo_name = "4leggedit-website"
production_branch = "main"
}
}
build_config = {
build_command = "npm run build"
destination_dir = "dist"
}
}
resource "cloudflare_pages_domain" "www" {
account_id = var.cloudflare_account_id
project_name = cloudflare_pages_project.site.name
domain = "www.4leggedit.com"
}The project appears under Workers & Pages in the dashboard, building from the branch you configured.
www.4leggedit.com resolves to the Pages deployment once its custom domain is attached — Cloudflare provisions the underlying CNAME automatically.
Step 7 — Apply your zone settings baseline
Zone-wide settings like TLS mode and HSTS are easy to configure once by hand and then forget — codifying them means every zone this configuration touches gets the same baseline automatically.
This example sets SSL to Full (strict), enforces TLS 1.2+, enables TLS 1.3, forces HTTPS, and turns on HSTS.
- Add your baseline values to zone_settings_baseline in locals.tf.
- zone_settings.tf (created by Step 0) already has the resource that applies that baseline, plus a separate HSTS block since it takes an object value rather than a string — nothing to add there.
- Run terraform plan and review — a zone settings diff affects live traffic immediately on apply.
Expand to copy: locals.tf settings baselineCopy exactly as shown. Do not modify.
locals {
zone_settings_baseline = {
ssl = "strict"
min_tls_version = "1.2"
tls_1_3 = "on"
always_use_https = "on"
}
}Expand to copy: zone_settings.tfCopy exactly as shown. Do not modify.
resource "cloudflare_zone_setting" "settings" {
for_each = {
for pair in setproduct(keys(local.zones), keys(local.zone_settings_baseline)) :
"${pair[0]}|${pair[1]}" => { zone = pair[0], setting_id = pair[1] }
}
zone_id = local.zones[each.value.zone].zone_id
setting_id = each.value.setting_id
value = local.zone_settings_baseline[each.value.setting_id]
}
resource "cloudflare_zone_setting" "hsts" {
for_each = local.zones
zone_id = each.value.zone_id
setting_id = "security_header"
value = {
strict_transport_security = {
enabled = true
max_age = 31536000
include_subdomains = true
preload = true
nosniff = true
}
}
}terraform plan shows only the settings you intentionally changed.
The zone's SSL/TLS page in the dashboard reflects the values you applied.
Step 8 — Configure bot management
Cloudflare's bot management settings let you push back on AI crawlers and other automated traffic without touching page rules or your origin server.
This example manages the zone's AI bot protection and Fight Mode as one resource.
- bot_management.tf (created by Step 0) already has the resource for your zone — nothing to add there; it takes effect as soon as your zone is in locals.tf.
- Run terraform plan and review before applying — this changes how bots are treated across the whole zone, not just one page.
Expand to copy: bot_management.tfCopy exactly as shown. Do not modify.
resource "cloudflare_bot_management" "this" {
for_each = local.zones
zone_id = each.value.zone_id
ai_bots_protection = "block"
fight_mode = true
}terraform plan shows the expected bot management change.
The zone's Bots page in the dashboard reflects the values you applied.
Step 9 — Enable DNSSEC
DNSSEC adds a cryptographic signature to your DNS responses so resolvers can detect tampering.
Turning it on in Cloudflare is only half the job — you also need to add the DS record it gives you at your domain's registrar.
- dnssec.tf (created by Step 0) already has the resource for your zone — nothing to add there; it takes effect as soon as your zone is in locals.tf.
- Apply, then read the DS record Cloudflare generates from the dashboard.
- Add that DS record at your registrar — Cloudflare cannot do this step for you if the domain is registered elsewhere.
Expand to copy: dnssec.tfCopy exactly as shown. Do not modify.
resource "cloudflare_zone_dnssec" "this" {
for_each = local.zones
zone_id = each.value.zone_id
}The zone's DNS settings page in the dashboard shows DNSSEC as Active once the registrar-side DS record propagates.
A DNSSEC-aware lookup for your domain returns valid signature records.
Step 10 — Turn on Email Routing and add a forwarding rule
This step turns on the Email Routing feature for the zone, adds the DNS records it needs, and creates a real forwarding rule — this example forwards info@4leggedit.com to an external address, something@example.com.
Cloudflare requires the destination inbox to be verified before it will actually forward mail there. Applying this step sends a verification email to that address — someone with access to that inbox has to open it and click the verification link Cloudflare sends. Terraform can create the pending request, but it can't click that link for you — the same kind of manual step as the GitHub authorization in Step 6.
- email_routing.tf (created by Step 0) already has the cloudflare_email_routing_dns resource that turns the feature on and adds the required DNS records — nothing to add there.
- Uncomment the cloudflare_email_routing_address and cloudflare_email_routing_rule resources in that same file and adapt them to your domain and destination inbox. This needs cloudflare_account_id — reuse the variable from Step 6, or declare it here if you skipped that step.
- Run terraform apply, then open the destination inbox and click the verification link Cloudflare sends.
- Send a test email to info@4leggedit.com and confirm it arrives at the destination inbox.
Expand to copy: email_routing.tfCopy exactly as shown. Do not modify.
resource "cloudflare_email_routing_dns" "onboard" {
for_each = local.zones
zone_id = each.value.zone_id
}
resource "cloudflare_email_routing_address" "forward_target" {
account_id = var.cloudflare_account_id
email = "something@example.com"
}
resource "cloudflare_email_routing_rule" "info_forward" {
zone_id = local.zones["4leggedit.com"].zone_id
name = "info@4leggedit.com -> something@example.com"
enabled = true
priority = 0
matchers = [{
type = "literal"
field = "to"
value = "info@4leggedit.com"
}]
actions = [{
type = "forward"
value = [cloudflare_email_routing_address.forward_target.email]
}]
depends_on = [cloudflare_email_routing_address.forward_target, cloudflare_email_routing_dns.onboard]
}The zone's Email Routing page in the dashboard shows the feature as Enabled, with one rule listed for info@4leggedit.com.
The destination address shows as Verified in the dashboard only after you click the verification link — until then the rule exists but nothing actually forwards.
A test email sent to info@4leggedit.com arrives at the destination inbox once verification is complete.
Step 11 — Add mail-authentication records (SPF/DMARC)
Step 10 turned on Email Routing and created a forward — these two records round that out with the SPF and DMARC a domain needs when it receives mail purely through Cloudflare Email Routing. SPF authorizes Cloudflare's own mail relay (include:_spf.mx.cloudflare.net) rather than a specific mail provider, and there's no DKIM record, because a receive-only forwarding domain never sends its own mail to sign.
These two records are illustrative of that pattern, not a description of 4leggedit.com's real configuration — its actual mail runs on iCloud, with a different, provider-specific SPF/DKIM/MX trio (a different pattern than this guide covers). SPF, DKIM, and DMARC are always public DNS records by design, so there's nothing sensitive about declaring any of them here.
The rua= address is where DMARC's aggregate reports go — point it at a mailbox you monitor yourself and you're on the hook for reading raw XML reports by hand. Cloudflare's own DMARC Management does that parsing for you instead: PATCH /zones/{zone_id}/email/auth/dmarc-reports with {"enabled": true} enables it for a zone and returns a rua_prefix — build the address as rua=mailto:<rua_prefix>@dmarc-reports.cloudflare.net. The call is idempotent, so it's safe to run before every apply that touches this record.
- Run ./enable-dmarc-management.sh (created by Step 0, alongside your other .tf files — needs the Email Security permission on your token, in addition to Step 1's zone-level ones) and note the rua_prefix it prints for your zone.
- Add both records to your dns_records map in locals.tf, alongside the www CNAME from Step 4 — use your captured rua_prefix in the DMARC record's content.
- dns.tf already has the resource that loops over that map — nothing to add there.
- Run terraform plan and review — mail-authentication records affect deliverability immediately.
- Apply, then confirm each record resolves as expected.
Expand to copy: locals.tf mail recordsCopy exactly as shown. Do not modify.
locals {
dns_records = {
# ...your existing entries (like the www CNAME from Step 4)...
"4leggedit.com|TXT|@" = {
domain = "4leggedit.com"
name = "4leggedit.com"
type = "TXT"
content = "v=spf1 include:_spf.mx.cloudflare.net -all"
proxied = false
ttl = 1
}
"4leggedit.com|TXT|_dmarc" = {
domain = "4leggedit.com"
name = "_dmarc.4leggedit.com"
type = "TXT"
# rua_prefix comes from enabling DMARC Management first:
# PATCH /zones/{zone_id}/email/auth/dmarc-reports {"enabled": true}
content = "v=DMARC1; p=reject; rua=mailto:<rua_prefix>@dmarc-reports.cloudflare.net; fo=1"
proxied = false
ttl = 1
}
}
}terraform plan shows the expected create for exactly these two records, and nothing else.
A TXT lookup for 4leggedit.com and _dmarc.4leggedit.com returns the SPF and DMARC records, with the DMARC record's rua= pointing at your own dmarc-reports.cloudflare.net address rather than a mailbox you have to check by hand.
Step 12 — Plan, apply, and review the diff safely
terraform plan is where you catch mistakes before they touch a live zone — read every line, not just the summary count at the bottom.
Saving the plan to a file and applying that exact file, rather than re-running apply on its own, guarantees you apply exactly what you reviewed.
- Run terraform init once per machine or after adding a provider.
- Run terraform plan and save the output to a file.
- Read the plan — pay extra attention to any DNS, redirect, or Email Routing change, since those affect live traffic and mail immediately.
- Apply the saved plan file.
Expand to copy: InitializeCopy exactly as shown. Do not modify.
terraform initExpand to copy: PlanCopy exactly as shown. Do not modify.
terraform plan -out=tfplanExpand to copy: ApplyCopy exactly as shown. Do not modify.
terraform apply tfplanterraform apply reports the exact number of resources you expected to add, change, or destroy — zero surprises.
The Cloudflare dashboard reflects every change you just applied.
Official References
These are primary sources for the tools and resources used in this guide.
Use them when your zone's existing configuration differs from the examples shown here.
Related How-To Sessions
How to Set Up Your Cloudflare Account
Start here if your domain isn't on Cloudflare yet — links onward to registering, transferring, or just moving DNS.
Publish Your Website with Cloudflare Pages + GitHub
The full dashboard walkthrough for connecting GitHub and deploying a Pages project, including the one-time authorization step.
DNS Records 101 (Cloudflare)
A plain-language primer on the record types this guide manages as code.
Cloudflare Email Forwarding
The full dashboard walkthrough for forwarding rules, destination verification, and catch-all addresses — this guide's Step 10 covers the same ground with Terraform.
macOS Command Line Project Management (GitHub + npm)
The Terminal, Git, and GitHub baseline this guide assumes.
Need Help Putting Your Cloudflare Setup Under Terraform?
If you want 4leggedIT to help you migrate an existing Cloudflare zone to Terraform, or build a repeatable infrastructure-as-code setup for your team, contact us.
