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

> Manage Cloudflare with Terraform or OpenTofu: DNS records, WAF, redirect, cache and rate limit rules, zone settings, DNSSEC, R2 buckets and tunnels.

- Source: https://www.itwonderlab.com/terraform-cloudflare-getting-started/
- Published: 2026-05-17
- Updated: 2026-05-17
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## Cloudflare with Terraform

[Cloudflare](https://www.itwonderlab.com/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](https://www.itwonderlab.com/terraform-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](https://github.com/cloudflare/terraform-provider-cloudflare/blob/main/docs/guides/version-5-upgrade.md) 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:

```shell
$ export CLOUDFLARE_API_TOKEN="<your token>"
```

### Provider

```hcl title="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:

```hcl title="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

```hcl title="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`](https://www.itwonderlab.com/terraform-for-each-vs-count/) 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:

```hcl title="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`):

```hcl title="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:

```hcl title="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:

```hcl title="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:

```hcl title="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:

```hcl title="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:

```hcl title="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:

```shell
$ 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](https://www.itwonderlab.com/terraform-state-file-encryption/). 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

```shell
$ 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:

```shell
$ 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](https://www.itwonderlab.com/terraform-import-moved-removed/) 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](https://www.itwonderlab.com/terraform-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](https://www.itwonderlab.com/terraform-drift/) and `apply` puts the record back as written in the code.

### Compare with AWS

| AWS | Cloudflare |
|---|---|
| [Route 53](https://www.itwonderlab.com/aws-route-53/) hosted zone | Zone |
| Route 53 record | `cloudflare_dns_record` |
| [CloudFront](https://www.itwonderlab.com/amazon-cloudfront/) | Proxy and CDN (`proxied = true`) |
| AWS WAF web ACL | Rulesets by phase |
| [S3](https://www.itwonderlab.com/aws-s3/) | R2 |
| Application load balancer | `cloudflare_load_balancer` |
| [SSM Session Manager](https://www.itwonderlab.com/terraform-aws-ssm-session-manager/) | Tunnel and Zero Trust Access |

For the same workflow on other platforms read [Azure](https://www.itwonderlab.com/terraform-azure-getting-started/) and [Google Cloud](https://www.itwonderlab.com/terraform-gcp-getting-started/). Next, read [project structure](https://www.itwonderlab.com/terraform-project-structure/) and [best practices](https://www.itwonderlab.com/terraform-best-practices/).
