Skip to content

field_note · jul 19

cloudflared tunnel token in Terraform v5: the tunnel_secret example that actually works

The cloudflare_zero_trust_tunnel_cloudflared_token data source returns a token, not tunnel_secret. Here's the v5 schema, where tunnel_secret really lives, and a full working example.

If you came here searching for tunnel_secret on the cloudflare_zero_trust_tunnel_cloudflared_token data source, here's the short answer: it isn't there. That data source returns a token, and tunnel_secret is an argument on a different object entirely. The two get conflated constantly because they both sound like "the secret thing that authenticates the tunnel." They're not the same, and the Cloudflare provider v5 schema is explicit about it. Let me lay out both, then show a full example that wires them together correctly.

The two things people mix up

There are two secrets in play when you stand up a cloudflared tunnel with the v5 provider:

  • tunnel_secret is an input you set on the resource cloudflare_zero_trust_tunnel_cloudflared. It's the password for a locally-managed tunnel.
  • token is an output you read from the data source cloudflare_zero_trust_tunnel_cloudflared_token. It's what you hand to the cloudflared connector to run the tunnel.

You set one; you read the other. Reaching for tunnel_secret on the token data source is looking for the input on the output.

The data source schema (v5)

Here's the actual schema for cloudflare_zero_trust_tunnel_cloudflared_token in provider v5:

terminal
data "cloudflare_zero_trust_tunnel_cloudflared_token" "this" {
  account_id = "699d98642c564d2e855e9661899b7252"  # required
  tunnel_id  = "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415"  # required
}
  • Required: account_id (String), tunnel_id (String, the tunnel UUID).
  • Read-Only: token (String, Sensitive) — the tunnel token used to authenticate the connector.

That's the whole surface. No tunnel_secret attribute, computed or otherwise. Note the v5 argument is tunnel_id; if you're porting from an older config that referenced the tunnel differently, this is one of the rename gotchas in the v5 rewrite.

Where tunnel_secret actually lives

tunnel_secret is an optional, sensitive argument on the resource, and its rules are specific:

tunnel_secret (String, Sensitive) — Sets the password required to run a locally-managed tunnel. Must be at least 32 bytes and encoded as a base64 string.

Two things follow from that:

  1. It's tied to locally-managed tunnels (config_src = "local" or "cloudflare"), and you supply it, Cloudflare doesn't hand it back.
  2. It must be ≥ 32 bytes, base64-encoded. Feed it a short or non-base64 string and the apply fails.

Don't type a base64 string by hand. Generate it.

A full working example

This creates the tunnel, generates a valid tunnel_secret, and reads the connector token, all in one config:

terminal
terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

variable "account_id" {
  type = string
}

# 32 random bytes, exposed base64-encoded — exactly what tunnel_secret wants.
resource "random_bytes" "tunnel_secret" {
  length = 32
}

resource "cloudflare_zero_trust_tunnel_cloudflared" "this" {
  account_id    = var.account_id
  name          = "blog"
  config_src    = "cloudflare"
  tunnel_secret = random_bytes.tunnel_secret.base64_std
}

# The token data source: account_id + the tunnel's UUID (its id).
data "cloudflare_zero_trust_tunnel_cloudflared_token" "this" {
  account_id = var.account_id
  tunnel_id  = cloudflare_zero_trust_tunnel_cloudflared.this.id
}

output "tunnel_token" {
  value     = data.cloudflare_zero_trust_tunnel_cloudflared_token.this.token
  sensitive = true
}

Then run the connector with the token:

terminal
cloudflared tunnel run --token "$(terraform output -raw tunnel_token)"

A few things worth calling out:

  • random_bytes.base64_std gives you a base64 string of exactly the byte length you asked for. length = 32 satisfies the "at least 32 bytes" rule with nothing to round.
  • tunnel_id = ...this.id — the resource's id is the tunnel UUID, so the data source keys off the resource you just made. That dependency also orders the apply correctly.
  • Both are sensitive. Mark the output sensitive = true and keep in mind Terraform state stores both values in plaintext. Treat the state file accordingly (encrypted remote backend, restricted access).

The gotchas, collected

  • tunnel_secret is not returned by the token data source. It's an input on the resource. If you need the secret elsewhere, reference random_bytes.tunnel_secret.base64_std, not the data source.
  • tunnel_secret must be ≥ 32 bytes, base64. Generate it; don't hand-write it.
  • In v5 the data source argument is tunnel_id, and the value you pass is the resource's id.
  • The token you actually run cloudflared with comes from the data source, and it's what most tutorials mean by "the tunnel token."
  • State holds both secrets in the clear. A remote backend with encryption isn't optional here.

The takeaway

The confusion is entirely a naming collision: tunnel_secret (the password you set on the resource) and token (the connector credential you read from the data source) are different objects doing different jobs. Once you see that the data source only ever gives you token, the v5 config falls out cleanly: generate the secret with random_bytes, set it on the resource, read the token from the data source, run the connector. Everything above applies to the cloudflare/cloudflare v5 provider.

Working through something like this? I help teams ship AI and cloud systems that hold up, and cost what they should.