Terraform and OpenTofu with Cloudflare: DNS, WAF, Caching, R2 and Tunnels
Cloudflare with Terraform #
Cloudflare is not an infrastructure cloud: you do not create servers with it. You manage the edge in front of your servers: DNS, the proxy and CDN, TLS settings, the web application firewall (WAF), object storage (R2) and secure tunnels to your origin. All of this can be written as code with the cloudflare provider for Terraform and OpenTofu, so that DNS changes are reviewed in a pull request and a deleted record can be restored from Git.
This tutorial uses the version 5 provider. Version 5 was rewritten from Cloudflare's API definition: resources use attributes with = instead of nested blocks, and some resources were renamed. For example, cloudflare_record of version 4 is now cloudflare_dns_record. If you have a version 4 project, read the upgrade guide first.
Create an API token #
Use an API token, not the legacy global API key. In the Cloudflare dashboard open My Profile, API Tokens, Create Token, and give it only what Terraform needs, limited to your zone:
Zone/DNS/Editto manage DNS records.Zone/Zone/Readto find the zone by name.Zone/Zone WAF/Editto manage the firewall and rate limiting rules.Zone/Zone Settings/Edit,Zone/Transform Rules/EditandZone/Cache Rules/Editfor the settings, redirects and caching sections.Account/Workers R2 Storage/EditandAccount/Cloudflare Tunnel/Editfor the R2 and tunnel sections.
The names of the permissions in the token screen change from time to time. If an apply fails with an authentication or permission error, the message names the missing permission.
The provider reads the token from an environment variable, so it never goes into a file:
$ export CLOUDFLARE_API_TOKEN="<your token>"
Provider #
terraform {
required_version = ">= 1.6"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.0"
}
}
}
provider "cloudflare" {
# api_token is read from CLOUDFLARE_API_TOKEN
}
variable "domain" {
type = string
description = "A domain already added to your Cloudflare account"
}Find the zone #
The domain must already exist in your Cloudflare account (added from the dashboard, with its name servers delegated). Look it up with a data source instead of copying the zone ID:
data "cloudflare_zone" "main" {
filter = {
name = var.domain
}
}You can also create the zone with the cloudflare_zone resource (it needs name and account = { id = "..." }), but then you must change the name servers at your registrar before the zone becomes active.
DNS records #
resource "cloudflare_dns_record" "apex" {
zone_id = data.cloudflare_zone.main.id
name = var.domain
type = "A"
content = "203.0.113.10"
ttl = 1 # 1 means automatic
proxied = true
comment = "Web server, managed by Terraform"
}
resource "cloudflare_dns_record" "www" {
zone_id = data.cloudflare_zone.main.id
name = "www"
type = "CNAME"
content = var.domain
ttl = 1
proxied = true
}
resource "cloudflare_dns_record" "mx" {
zone_id = data.cloudflare_zone.main.id
name = var.domain
type = "MX"
content = "mail.example.net"
priority = 10
ttl = 3600
}
resource "cloudflare_dns_record" "spf" {
zone_id = data.cloudflare_zone.main.id
name = var.domain
type = "TXT"
content = "\"v=spf1 mx -all\""
ttl = 3600
}Points to know:
ttlis required. The value1means automatic, and it is the only value allowed for proxied records. Otherwise it must be between 60 and 86400 seconds.proxied = truesends web traffic through Cloudflare (CDN and WAF) and hides the origin IP. Mail records and anything that is not HTTP must stayproxied = false.- The
203.0.113.10address is a documentation address: use your own server IP. - To create many similar records use
for_eachover a map instead of copying the block.
A custom WAF rule #
Cloudflare rules are grouped in rulesets, one per phase. The custom rules of the firewall live in the phase http_request_firewall_custom. This ruleset challenges every visitor to /admin except one trusted IP address:
resource "cloudflare_ruleset" "custom_rules" {
zone_id = data.cloudflare_zone.main.id
name = "Custom rules"
description = "Managed by Terraform"
kind = "zone"
phase = "http_request_firewall_custom"
rules = [
{
ref = "protect_admin"
description = "Challenge /admin unless the request comes from the office"
expression = "(starts_with(http.request.uri.path, \"/admin\") and ip.src ne 198.51.100.7)"
action = "managed_challenge"
enabled = true
}
]
}Notes:
- The
expressionuses the Cloudflare rules language.198.51.100.7is a documentation address: replace it with your own. - A zone has one ruleset per phase. If you already created custom rules in the dashboard, import that ruleset first (below) or Terraform will fail or overwrite it.
- Other actions you can use include
block,challenge,js_challenge,logandskip. Uselogfirst to see what a rule would match before you block.
More rules: redirects, caching and rate limiting #
Every kind of rule is a cloudflare_ruleset in its own phase, and a zone has one ruleset per phase. The same resource then covers redirects, cache rules and rate limits.
A redirect rule (phase http_request_dynamic_redirect):
resource "cloudflare_ruleset" "redirects" {
zone_id = data.cloudflare_zone.main.id
name = "Redirects"
kind = "zone"
phase = "http_request_dynamic_redirect"
rules = [
{
ref = "old_page"
description = "Old page moved"
expression = "(http.request.uri.path eq \"/old-page\")"
action = "redirect"
action_parameters = {
from_value = {
status_code = 301
target_url = {
value = "https://${var.domain}/new-page"
}
preserve_query_string = true
}
}
}
]
}A cache rule (phase http_request_cache_settings) that caches static files at the edge for a day and in the browser for an hour, whatever the origin headers say:
resource "cloudflare_ruleset" "cache" {
zone_id = data.cloudflare_zone.main.id
name = "Cache rules"
kind = "zone"
phase = "http_request_cache_settings"
rules = [
{
ref = "static_files"
description = "Cache /static"
expression = "(starts_with(http.request.uri.path, \"/static/\"))"
action = "set_cache_settings"
action_parameters = {
cache = true
edge_ttl = {
mode = "override_origin"
default = 86400
}
browser_ttl = {
mode = "override_origin"
default = 3600
}
}
}
]
}A rate limit (phase http_ratelimit) that blocks a client that sends more than 20 requests in 10 seconds to the login page:
resource "cloudflare_ruleset" "ratelimit" {
zone_id = data.cloudflare_zone.main.id
name = "Rate limiting"
kind = "zone"
phase = "http_ratelimit"
rules = [
{
ref = "login"
description = "Limit requests to /login"
expression = "(starts_with(http.request.uri.path, \"/login\"))"
action = "block"
ratelimit = {
characteristics = ["cf.colo.id", "ip.src"]
period = 10
requests_per_period = 20
mitigation_timeout = 10
}
}
]
}The periods, the characteristics and the number of rules you can use depend on your Cloudflare plan. If the apply is rejected, the error message says which value your plan does not allow.
Zone settings and DNSSEC #
The settings of the dashboard (SSL/TLS mode, HTTPS redirect, minimum TLS version and many more) are cloudflare_zone_setting resources, one per setting. A map and for_each keep them in one place:
locals {
zone_settings = {
ssl = "strict"
always_use_https = "on"
min_tls_version = "1.2"
automatic_https_rewrites = "on"
http3 = "on"
}
}
resource "cloudflare_zone_setting" "this" {
for_each = local.zone_settings
zone_id = data.cloudflare_zone.main.id
setting_id = each.key
value = each.value
}
resource "cloudflare_zone_dnssec" "main" {
zone_id = data.cloudflare_zone.main.id
status = "active"
}
output "dnssec_ds_record" {
value = cloudflare_zone_dnssec.main.ds
}ssl = "strict"(Full strict) makes Cloudflare verify the certificate of your origin. Your origin must have a valid certificate (for example a free Cloudflare Origin CA certificate) or visitors get errors.- The valid
setting_idvalues and their values are listed in the documentation of the resource. Some settings need a paid plan. - DNSSEC signs the zone, but it only works after you add the DS record (the output above) at your domain registrar. If you do not, the domain can stop resolving for validating resolvers, so do it with care.
An R2 bucket #
R2 is the object storage of Cloudflare. It is compatible with the S3 API, and it does not charge for traffic out of the bucket. The account ID comes from the zone, so you do not need another variable:
resource "cloudflare_r2_bucket" "assets" {
account_id = data.cloudflare_zone.main.account.id
name = "ditwl-assets"
location = "weur"
storage_class = "Standard"
}location is a hint for where the bucket is created (apac, eeur, enam, weur, wnam or oc) and it is only used when the bucket is first created. Use jurisdiction (eu or fedramp, for example) when the data must stay in a region by law. The S3 API credentials of R2 are created in the dashboard.
A tunnel to a private server #
A Cloudflare Tunnel connects an origin to Cloudflare with an outbound connection from a small program, cloudflared. The server needs no public IP address and no open inbound port. This creates a tunnel that is configured remotely, a route for app.example.com and the DNS record:
resource "random_id" "tunnel_secret" {
byte_length = 32
}
resource "cloudflare_zero_trust_tunnel_cloudflared" "app" {
account_id = data.cloudflare_zone.main.account.id
name = "app"
config_src = "cloudflare"
tunnel_secret = random_id.tunnel_secret.b64_std
}
resource "cloudflare_zero_trust_tunnel_cloudflared_config" "app" {
account_id = data.cloudflare_zone.main.account.id
tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.app.id
config = {
ingress = [
{
hostname = "app.${var.domain}"
service = "http://localhost:8080"
},
{
service = "http_status:404" # required catch-all rule
}
]
}
}
resource "cloudflare_dns_record" "app" {
zone_id = data.cloudflare_zone.main.id
name = "app"
type = "CNAME"
content = "${cloudflare_zero_trust_tunnel_cloudflared.app.id}.cfargotunnel.com"
ttl = 1
proxied = true
}
data "cloudflare_zero_trust_tunnel_cloudflared_token" "app" {
account_id = data.cloudflare_zone.main.account.id
tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.app.id
}
output "tunnel_token" {
value = data.cloudflare_zero_trust_tunnel_cloudflared_token.app.token
sensitive = true
}On the server install cloudflared and start it with the token:
$ cloudflared tunnel run --token "$(tofu output -raw tunnel_token)"
The service at localhost:8080 is then available at https://app.example.com. The secret and the token are stored in the state, so use an encrypted remote state. The last ingress rule, without a hostname, is required: it answers all other requests.
What else you can manage #
The provider has more than 250 resources. These are the groups you will meet next, each with its own resources named in the documentation:
- Load balancing:
cloudflare_load_balancerwith pools and health monitors, to spread traffic between origins and fail over. - Zero Trust Access:
cloudflare_zero_trust_access_applicationandcloudflare_zero_trust_access_policyput a login in front of an internal application, with no VPN. - Workers:
cloudflare_workers_script, routes and KV namespaces for code that runs at the edge. - Pages:
cloudflare_pages_projectfor static sites and front ends. - Email routing:
cloudflare_email_routing_ruleforwardsinfo@example.comto a mailbox. - Turnstile:
cloudflare_turnstile_widget, a CAPTCHA alternative for your forms. - Logs and alerts:
cloudflare_logpush_jobsends logs to a bucket andcloudflare_notification_policysends alerts. - Certificates:
cloudflare_origin_ca_certificateandcloudflare_certificate_pack.
Run it #
$ tofu init
$ tofu plan -var domain=example.com
$ tofu apply -var domain=example.com
Replace example.com with your domain. DNS changes reach Cloudflare's network in seconds, so test the plan with care: a wrong record on a live domain is an outage. Use terraform instead of tofu if you prefer HashiCorp Terraform.
Import what already exists #
A domain that has been running for years has dozens of records. Import them instead of re-creating them. The ID of a DNS record is the zone ID and the record ID, separated by a slash:
$ terraform import cloudflare_dns_record.apex '<zone_id>/<dns_record_id>'
Since version 1.5 of Terraform and OpenTofu you can use an import block in the code instead of the command. Find the IDs in the dashboard or in the API.
State and drift #
Cloudflare has no resource that stores the state for you, so use a remote backend and keep the file safe: it contains no token, but it describes your whole edge configuration. If someone changes a record in the dashboard, the next plan shows the drift and apply puts the record back as written in the code.
Compare with AWS #
| AWS | Cloudflare |
|---|---|
| Route 53 hosted zone | Zone |
| Route 53 record | cloudflare_dns_record |
| CloudFront | Proxy and CDN (proxied = true) |
| AWS WAF web ACL | Rulesets by phase |
| S3 | R2 |
| Application load balancer | cloudflare_load_balancer |
| SSM Session Manager | Tunnel and Zero Trust Access |
For the same workflow on other platforms read Azure and Google Cloud. Next, read project structure and best practices.