Terraform and OpenTofu with Cloudflare: DNS, WAF, Caching, R2 and Tunnels

· 7 min read · Terraform & OpenTofu Tutorials

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 / Edit to manage DNS records.
  • Zone / Zone / Read to find the zone by name.
  • Zone / Zone WAF / Edit to manage the firewall and rate limiting rules.
  • Zone / Zone Settings / Edit, Zone / Transform Rules / Edit and Zone / Cache Rules / Edit for the settings, redirects and caching sections.
  • Account / Workers R2 Storage / Edit and Account / Cloudflare Tunnel / Edit for 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 #

providers.tf
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:

zone.tf
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 #

dns.tf
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:

  • ttl is required. The value 1 means automatic, and it is the only value allowed for proxied records. Otherwise it must be between 60 and 86400 seconds.
  • proxied = true sends web traffic through Cloudflare (CDN and WAF) and hides the origin IP. Mail records and anything that is not HTTP must stay proxied = false.
  • The 203.0.113.10 address is a documentation address: use your own server IP.
  • To create many similar records use for_each over 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:

waf.tf
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 expression uses the Cloudflare rules language. 198.51.100.7 is 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, log and skip. Use log first 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):

redirects.tf
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:

cache.tf
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:

ratelimit.tf
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:

settings.tf
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_id values 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:

r2.tf
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:

tunnel.tf
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_balancer with pools and health monitors, to spread traffic between origins and fail over.
  • Zero Trust Access: cloudflare_zero_trust_access_application and cloudflare_zero_trust_access_policy put 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_project for static sites and front ends.
  • Email routing: cloudflare_email_routing_rule forwards info@example.com to a mailbox.
  • Turnstile: cloudflare_turnstile_widget, a CAPTCHA alternative for your forms.
  • Logs and alerts: cloudflare_logpush_job sends logs to a bucket and cloudflare_notification_policy sends alerts.
  • Certificates: cloudflare_origin_ca_certificate and cloudflare_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.

#Terraform #OpenTofu #Cloudflare #DNS