Skip to main content

Import Cloudflare Resources to Opentofu

·6 mins

I’ve been using Cloudflare for a couple of years now. R2, Cloudflare Cache, or DNS management are great products I rely on in my personal projects. Even this website’s domain is managed through Cloudflare.

As my infrastructure grew, I noticed I was repeating the same manual configurations across multiple resources. Even though these are my hobby projects, I thought it would be cool to learn how I can move my Cloudflare setup to OpenTofu. This way I could manage everything in code and avoid doing the same work over and over.

In this note I’ll focus on the most important aspects, assuming you already know the basics of OpenTofu and TACOS.

  • Creating and importing Cloudflare API tokens using OpenTofu’s import blocks
  • Building reusable configurations with dynamic permission groups
  • Understanding the differences between User API Tokens and Account API Tokens
  • Avoiding common pitfalls I ran into

The complete working configuration is available in my infrastructure repository for reference.

Prerequisites #

If you want to repeat this approach yourself, here’s what I used:

  • A Cloudflare account
  • OpenTofu CLI
  • curl and yq to parse Cloudflare API responses
  • Optional: A TACOS platform like Spacelift for remote state

Note: I ended up using User API Tokens instead of Account API Tokens after running into some issues. See the Caveats section below for details.

Configure your Cloudflare provider #

Create Cloudflare API token #

First, I set up the Cloudflare provider by creating a User API token1 with these permission groups:

  • User: API Tokens Read - required to access the token,
  • User: API Tokens Write - required to change the tokens permission groups
  • User: User Details Read - required to fetch available permissions groups.

I store the token value in 1Password to keep it safe. If you’re using a TACOS, you can set it as the CLOUDFLARE_API_TOKEN environment variable, as I did.

References:

Configure OpenTofu provider #

With the token ready, I set up the provider configuration. Here’s the basic setup:

terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = ">=4.0,<5"
    }
  }
}

provider "cloudflare" {}

The provider automatically reads from the CLOUDFLARE_API_TOKEN environment variable (you can pass it explicitly via an OpenTofu variable as well).

See the Cloudflare Provider Getting Started guide for more configuration options.

Solving the chicken and egg problem #

Like most IaC bootstrapping scenarios, there’s a chicken and egg problem: you need an API token to manage resources, but ideally you’d want that token itself managed by IaC.

The solution is to create the initial token manually, then import it into OpenTofu. Once imported, you can manage the token in code going forward.

The permission groups we created earlier are enough to import and modify the API token itself - so the token can manage itself.

Here’s what you need to import the API token:

  • Cloudflare Account ID (put directly in locals)
  • Cloudflare User API Token ID (for the import statement)
  • Data source cloudflare_api_token_permission_groups_list to fetch available permission groups
  • Data source cloudflare_user to get user information
  • Resource cloudflare_api_token that we’re importing

Fetching Account ID #

I got my Account ID from the Cloudflare dashboard following their documentation, then added it to my configuration:

locals {
  cloudflare_account_id = "your-account-id-here"
}

Fetching API Token ID #

To get the API Token ID, I used the /user/tokens/verify endpoint. First, export your token as an environment variable, then run:

export CLOUDFLARE_API_TOKEN="your-token-here"

curl \
    --silent "https://api.cloudflare.com/client/v4/user/tokens/verify" \
    --header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
    | yq ".result.id"

This will return you a User API Token identifier

Helper variables for permission groups #

The cloudflare_api_token resource’s policies field expects permission group IDs. Instead of hardcoding these IDs, I fetch them automatically from the API using data sources.

Cloudflare permission groups have three scopes: user, account, and zone. I fetch each scope separately and create a helper map for easy lookups.

data "cloudflare_api_token_permission_groups_list" "user" {
  scope = urlencode("com.cloudflare.api.user")
}

data "cloudflare_api_token_permission_groups_list" "account" {
  scope = urlencode("com.cloudflare.api.account")
}

data "cloudflare_api_token_permission_groups_list" "zone" {
  scope = urlencode("com.cloudflare.api.account.zone")
}

data "cloudflare_user" "me" {}

locals {
  cloudflare_api_token_permission_groups = {
    user = {
      for permission_group in data.cloudflare_api_token_permission_groups_list.user.result :
      permission_group.name => permission_group
    }
    account = {
      for permission_group in data.cloudflare_api_token_permission_groups_list.account.result :
      permission_group.name => permission_group
    }
    zone = {
      for permission_group in data.cloudflare_api_token_permission_groups_list.zone.result :
      permission_group.name => permission_group
    }
  }
}

The local.cloudflare_api_token_permission_groups map has three keys (user, account, zone), each containing a name-to-permission-group mapping. This lets me reference permissions by name instead of hardcoding IDs.

Import Cloudflare User API Token #

Now that I have everything, I created the import statement and resource definition:

import {
  to = cloudflare_api_token.main
  id = "your-api-token-id-here"  # From the verify endpoint above
}

resource "cloudflare_api_token" "main" {
  name = "opentofu-management"
  policies = [
    {
      effect = "allow"
      permission_groups = [
        { id = local.cloudflare_api_token_permission_groups.user["API Tokens Read"].id },
        { id = local.cloudflare_api_token_permission_groups.user["API Tokens Write"].id },
        { id = local.cloudflare_api_token_permission_groups.user["User Details Read"].id },
      ]
      resources = {
        "com.cloudflare.api.user.${data.cloudflare_user.me.id}" = "*"
      }
    },
    {
      effect = "allow"
      permission_groups = [
        { id = local.cloudflare_api_token_permission_groups.account["Account API Tokens Read"].id },
        { id = local.cloudflare_api_token_permission_groups.account["Account API Tokens Write"].id },
        { id = local.cloudflare_api_token_permission_groups.account["Account Settings Read"].id },
      ]
      resources = jsonencode({
        "com.cloudflare.api.account.${local.cloudflare_account_id}" = "*"
      })
    }
  ]
  status = "active"
}

This configuration gives you detailed control over permission scopes. You can restrict permissions to specific accounts or users by changing the resources attribute.

Creating additional tokens #

Once the bootstrap token is imported, you can create additional tokens for specific purposes. Here’s an example of an Account API Token with restricted permissions:

resource "cloudflare_account_token" "readonly" {
  account_id = local.cloudflare_account_id
  name       = "readonly-settings"
  policies = [{
    effect = "allow"
    permission_groups = [
      { id = local.cloudflare_api_token_permission_groups.account["Account Settings Read"].id },
    ]
    resources = jsonencode({
      "com.cloudflare.api.account.${local.cloudflare_account_id}" = "*"
    })
  }]
}

This pattern lets you create purpose-specific tokens following the least privilege principle.

Caveats #

I ran into a few issues during this process that are worth sharing.

Cloudflare Provider did not work with Account API Token #

Initially, I tried using an Account API Token, but it failed with certain provider operations. The [Cloudflare documentation][account-token-compatability] doesn’t mention this incompatibility, but I ran into it myself.

│ Error: failed to make http request
│   with data.cloudflare_user.me,
│   on cloudflare.tofu line 1, in data "cloudflare_user" "me":
│    1: data "cloudflare_user" "me" {}
│ GET "https://api.cloudflare.com/client/v4/user": 403 Forbidden
│ {"success":false,"errors":[{"code":9109,"message":"Valid user-level
│ authentication not found"}],"messages":[],"result":null}

The error occurs because the cloudflare_user data source requires user-level authentication, which Account API Tokens don’t provide.

I could have avoided using cloudflare_user entirely and set things up differently - but for my personal infrastructure, switching to User API Tokens was simpler.

Slow plan execution with Account API Tokens #

While debugging the token type issue, I noticed that tofu plan was extremely slow when using Account API Tokens.

The provider seemed to hang when fetching account information. Switching to User API Tokens fixed the performance issue right away. This seems to be a known issue with the provider.

Breaking change in 5.13.0 version of provider #

Update to 5.13.0 introduced a breaking change. https://github.com/cloudflare/terraform-provider-cloudflare/releases/tag/v5.13.0

Conclusion #

Getting this initial API token into OpenTofu was tedious, but it paid off. Now I have a code-based setup for managing my entire Cloudflare infrastructure. No more clicking through the dashboard to recreate the same configurations across projects.

With the bootstrap token managed by IaC, I can easily create additional tokens for specific workflows without manual setup. The permission group helpers make it easy to define detailed permissions.

If you’re curious about the full implementation, check out my dev repository where I’m using this approach in production.