Everything in the lab is managed as code. FortiGate firewall rules, Proxmox VMs, Kubernetes manifests. Cloudflare was the last holdout. DNS records, tunnel config, and zone settings all lived in the dashboard, clicked into existence and never tracked anywhere. Time to fix that.

Contents#

The Goal#

Three things to accomplish:

  1. Import the existing yourdomain.tld zone and swagpipelineproxy tunnel into Terraform state
  2. Create two new zones: heezy.info and heezy.blog
  3. Stand up dedicated containers and tunnels for each new domain, decoupled from the existing SWAG stack

The blog was already running at blog.yourdomain.tld via SWAG. The plan was to move it to heezy.blog with its own container, its own tunnel, and its own build pipeline. heezy.info would start as a placeholder and eventually host server status pages.

The Import#

The Cloudflare Terraform workspace lives at environments/production/cloudflare/ in terraform-heezy. Same S3 backend as every other workspace, same pattern. The Cloudflare provider alongside the existing AWS and random providers:

terraform {
  backend "s3" {
    bucket  = "terraform-heezy-state"
    key     = "production/cloudflare/terraform.tfstate"
    region  = "us-east-2"
    encrypt = true
  }
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.0"
    }
  }
}

The Cloudflare API token lives in AWS Secrets Manager and gets pulled at runtime. Nothing in git. The provider block reads it from a local:

data "aws_secretsmanager_secret_version" "cloudflare" {
  secret_id = "production/heezy/terraform/cloudflare/secret"
}

locals {
  cloudflare_creds = jsondecode(data.aws_secretsmanager_secret_version.cloudflare.secret_string)
}

provider "cloudflare" {
  api_token = local.cloudflare_creds.api_token
}

The existing zone is referenced as a data source so Terraform can look up its ID without hardcoding it:

data "cloudflare_zone" "trentnielsen_me" {
  name = "yourdomain.tld"
}

cf-terraforming Was Broken#

The obvious tool for importing existing Cloudflare config is cf-terraforming. It wasn’t usable. v0.22.0 panicked on Terraform 1.7+. v0.24.0 and v0.26.0 both misread Cloudflare’s success response code 10000 as an auth error and bailed out immediately. The error looked like an auth failure but the token was fine. It was reading messages[0].code and treating any non-zero value as an error, including the 10000 that Cloudflare uses to indicate success.

Ended up writing a Python script that hit the Cloudflare API directly and generated the Terraform config. The script lives in terraform-heezy/scripts/generate-cf-terraform.py. It reads credentials from environment variables, paginates through all DNS records, handles the apex record name gotcha, and writes out .tf files with both resource definitions and import {} blocks ready to apply.

export CF_API_TOKEN=$(aws secretsmanager get-secret-value \
  --secret-id production/heezy/terraform/cloudflare/secret \
  --query SecretString --output text | python3 -c "import json,sys; print(json.load(sys.stdin)['api_token'])")
export CF_ZONE_ID=<zone-id>
export CF_ZONE_NAME=yourdomain.tld
export CF_ACCOUNT_ID=<account-id>
export CF_TUNNEL_NAME=swagpipelineproxy  # optional, omit to export all tunnels

python3 scripts/generate-cf-terraform.py

Output: two .tf files dropped in the current directory, ready to move into the workspace and apply.

Import Blocks, Not CLI#

Used Terraform 1.5+ import {} blocks instead of running terraform import commands manually. The generated .tf files include the import blocks inline alongside the resource definitions:

resource "cloudflare_record" "trentnielsen_me_apex" {
  zone_id = data.cloudflare_zone.trentnielsen_me.id
  name    = "yourdomain.tld"
  type    = "A"
  content = "192.0.2.1"
  proxied = true
}

import {
  to = cloudflare_record.trentnielsen_me_apex
  id = "<zone_id>/<record_id>"
}

Cleaner than running terraform import commands manually, and the import is tracked in git alongside the resource definition. Run terraform apply once and the resources are in state.

The DNS Record Name Gotcha#

The Cloudflare API returns record names as short labels (ut2k4, plex) but apex records come back as the full zone name (yourdomain.tld), not @. Getting this wrong causes Terraform to see a diff on every plan and want to destroy and recreate the record. The generated config has to match exactly what the API returns, not what you’d intuitively write.

The Tunnel#

swagpipelineproxy is a locally-managed tunnel. Ingress rules live in a Kubernetes ConfigMap, not the Cloudflare dashboard. Imported the tunnel resource itself into state using cloudflare_zero_trust_tunnel_cloudflared (the non-deprecated resource) with lifecycle { ignore_changes = [secret] } to prevent forced replacement on every apply:

resource "cloudflare_zero_trust_tunnel_cloudflared" "swagpipelineproxy" {
  account_id = local.cloudflare_creds.account_id
  name       = "swagpipelineproxy"
  secret     = "<redacted>"
  lifecycle {
    ignore_changes = [secret]
  }
}

import {
  to = cloudflare_zero_trust_tunnel_cloudflared.swagpipelineproxy
  id = "<account_id>/<tunnel_id>"
}

Final plan result: 19 to import, 2 to add, 18 to change, 0 to destroy. Zero destructive changes. Applied clean on the first run.

Two New Domains#

heezy.info and heezy.blog were registered at IONOS. Creating the zones in Terraform was straightforward. The new zones are resources rather than data sources since Terraform is creating them:

resource "cloudflare_zone" "heezy_info" {
  account_id = local.cloudflare_creds.account_id
  zone       = "heezy.info"
}

resource "cloudflare_zone" "heezy_blog" {
  account_id = local.cloudflare_creds.account_id
  zone       = "heezy.blog"
}

After apply, Cloudflare assigned nameservers to each zone. Those nameservers get entered at the registrar. Cloudflare polls for propagation and activates the zone once it sees the NS records pointing at its servers. Takes anywhere from a few minutes to a few hours depending on the registrar’s TTLs.

Dedicated Tunnels Per Container#

The existing swagpipelineproxy tunnel handles all of yourdomain.tld via a wildcard CNAME. That works, but it means one tunnel failure takes down everything. For the new domains, each container gets its own tunnel. If heezy.blog has a problem, heezy.info keeps running.

Dashboard-Managed vs. Locally-Managed#

The first attempt created the tunnels without config_src = "cloudflare", which makes them locally-managed by default. Locally-managed tunnels don’t expose a token through the dashboard or the API in the normal way. The Cloudflare UI shows a migration prompt instead of a token, and the migration is irreversible.

The fix: add config_src = "cloudflare" to make them dashboard-managed from the start. Dashboard-managed tunnels expose a token via the API, and ingress rules can be managed through cloudflare_zero_trust_tunnel_cloudflared_config resources in Terraform rather than a config file or ConfigMap.

The tunnel secret is generated by the random provider and base64-encoded. The ignore_changes lifecycle rule prevents Terraform from rotating it on every apply:

resource "cloudflare_zero_trust_tunnel_cloudflared" "heezy_blog" {
  account_id = local.cloudflare_creds.account_id
  name       = "heezy-blog"
  secret     = base64encode(random_password.heezy_blog_tunnel_secret.result)
  config_src = "cloudflare"
  lifecycle {
    ignore_changes = [secret]
  }
}

resource "random_password" "heezy_blog_tunnel_secret" {
  length  = 32
  special = false
}

Ingress rules are managed as a separate resource. The catch-all http_status:404 rule at the end is required — without it, cloudflared rejects the config:

resource "cloudflare_zero_trust_tunnel_cloudflared_config" "heezy_blog" {
  account_id = local.cloudflare_creds.account_id
  tunnel_id  = cloudflare_zero_trust_tunnel_cloudflared.heezy_blog.id

  config {
    ingress_rule {
      hostname = "heezy.blog"
      service  = "http://localhost:80"
    }
    ingress_rule {
      service = "http_status:404"
    }
  }
}

The DNS apex record points at the tunnel using the tunnel ID interpolated into the cfargotunnel.com hostname. Proxied through Cloudflare so the origin IP is never exposed:

resource "cloudflare_record" "heezy_blog_apex" {
  zone_id = cloudflare_zone.heezy_blog.id
  name    = "heezy.blog"
  type    = "CNAME"
  content = "${cloudflare_zero_trust_tunnel_cloudflared.heezy_blog.id}.cfargotunnel.com"
  ttl     = 1
  proxied = true
}

Getting the Tunnel Tokens#

After apply, the tunnel tokens aren’t visible in the Cloudflare dashboard UI. They live in the API at /cfd_tunnel/{id}/token. The credentials to call that API are already in Secrets Manager from the Terraform setup, so the same secret can be reused to fetch the tokens:

# Pull Cloudflare credentials from Secrets Manager
CREDS=$(aws secretsmanager get-secret-value \
  --secret-id production/heezy/terraform/cloudflare/secret \
  --region us-east-2 --query SecretString --output text)
ACCOUNT_ID=$(echo $CREDS | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['account_id'])")
API_TOKEN=$(echo $CREDS | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['api_token'])")

# Look up tunnel ID by name, then fetch the token
TUNNEL_ID=$(curl -s -H "Authorization: Bearer $API_TOKEN" \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel?name=heezy-blog" \
  | python3 -c "import json,sys; r=json.load(sys.stdin); print(r['result'][0]['id'])")

TOKEN=$(curl -s -H "Authorization: Bearer $API_TOKEN" \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/token" \
  | python3 -c "import json,sys; r=json.load(sys.stdin); print(r['result'])")

# Store in Secrets Manager for the k8s ExternalSecret to consume
aws secretsmanager create-secret \
  --name production/heezy/heezy-blog/cloudflare \
  --region us-east-2 \
  --secret-string "{\"TUNNEL_TOKEN\": \"$TOKEN\"}"

The k8s Side#

Each domain gets its own container in heezy-containers and its own deployment in heezy-k8s. The two are completely independent.

Container Structure#

heezy-blog is a Hugo build using the same terminal theme as the old blog.yourdomain.tld setup, with the baseURL updated to heezy.blog. heezy-info is the same stack with a placeholder homepage. Both are two-stage Docker builds: Hugo compiles the static site, nginx serves it.

heezy-containers/
├── dockerfiles/
│   ├── heezy-blog/
│   │   ├── Dockerfile
│   │   └── blog-content/     # Hugo site source
│   └── heezy-info/
│       ├── Dockerfile
│       └── site-content/     # Hugo site source
FROM hugomods/hugo:latest AS builder
WORKDIR /blog
COPY blog-content/ .
RUN git init && \
    git submodule add https://github.com/panr/hugo-theme-terminal.git themes/terminal && \
    hugo --minify

FROM nginx:alpine
COPY --from=builder /blog/public/ /usr/share/nginx/html/

Build and Deploy Pipelines#

Each container has its own GitHub Actions workflow with a path trigger scoped to its own directory. Touching heezy-blog files never triggers a heezy-info build and vice versa. The build job uses OIDC to assume an IAM role, pushes to ECR, then the deploy job fetches a GitHub token from Secrets Manager and triggers deploy-service.yaml in heezy-k8s via the GitHub API:

name: Build and Deploy heezy-blog

on:
  push:
    branches: [main]
    paths:
      - 'dockerfiles/heezy-blog/**'
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<account_id>:role/github-actions-ecr-push
          aws-region: us-east-2

      - name: Login to ECR
        run: |
          aws ecr get-login-password --region us-east-2 \
            | docker login --username AWS --password-stdin \
              <account_id>.dkr.ecr.us-east-2.amazonaws.com

      - name: Build and push
        run: |
          cd dockerfiles/heezy-blog
          docker build -t <account_id>.dkr.ecr.us-east-2.amazonaws.com/heezy-blog:latest .
          docker push <account_id>.dkr.ecr.us-east-2.amazonaws.com/heezy-blog:latest

  deploy:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<account_id>:role/GitHubActions-MultiRepo
          aws-region: us-east-2

      - name: Get GitHub token
        id: token
        run: |
          set +x
          CREDS=$(aws secretsmanager get-secret-value \
            --secret-id all/heezy/github/runner/personal-access-token \
            --query SecretString --output text)
          TOKEN=$(echo $CREDS | jq -r '.token')
          echo "token=$TOKEN" >> $GITHUB_OUTPUT

      - name: Trigger deploy in heezy-k8s
        run: |
          curl -X POST \
            -H "Authorization: Bearer ${{ steps.token.outputs.token }}" \
            -H "Accept: application/vnd.github.v3+json" \
            https://api.github.com/repos/[github-user]/heezy-k8s/actions/workflows/deploy-service.yaml/dispatches \
            -d '{"ref": "main", "inputs": {"service": "heezy-blog"}}'

k8s Deployment#

Each deployment runs two containers: cloudflared as a sidecar and nginx serving the static site. The tunnel sidecar connects outbound to Cloudflare, so no inbound firewall rules are needed anywhere. Traffic flows: internet → Cloudflare → tunnel → cloudflared sidecar → nginx on localhost:80.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: heezy-blog
  namespace: heezy
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: heezy-blog
  template:
    metadata:
      labels:
        app: heezy-blog
    spec:
      imagePullSecrets:
        - name: ecr-credentials
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared:latest
          args: [tunnel, --no-autoupdate, run]
          env:
            - name: TUNNEL_TOKEN
              valueFrom:
                secretKeyRef:
                  name: heezy-blog-secrets
                  key: TUNNEL_TOKEN
        - name: heezy-blog
          image: <account_id>.dkr.ecr.us-east-2.amazonaws.com/heezy-blog:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 80

The TUNNEL_TOKEN comes from an ExternalSecret that pulls from AWS Secrets Manager. The secret never lives in git. The ExternalSecret controller owns the k8s secret object and cleans it up if the ExternalSecret is deleted:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: heezy-blog-secrets
  namespace: heezy
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: heezy-blog-secrets
    creationPolicy: Owner
  dataFrom:
  - extract:
      key: production/heezy/heezy-blog/cloudflare

Lessons Learned#

cf-terraforming is broken. It misreads the messages[0].code = 10000 success indicator as an auth error. Use the Cloudflare API directly and generate the config yourself.

config_src = "cloudflare" is required for dashboard-managed tunnels. Without it, Terraform creates locally-managed tunnels that don’t expose tokens and can’t have their ingress rules managed via Terraform resources. The Cloudflare UI will show a migration prompt instead of a token, and migration is irreversible.

Tunnel tokens aren’t in the dashboard UI. Even for dashboard-managed tunnels, the token lives in the API at /cfd_tunnel/{id}/token. Fetch it programmatically and store it in Secrets Manager.

The catch-all ingress rule is required. Cloudflare rejects tunnel configs that don’t end with a catch-all rule. Always include service = "http_status:404" as the last ingress rule.

Apex records use the full zone name, not @. The Cloudflare API returns yourdomain.tld for the apex record name. Terraform config must match exactly or you get a perpetual diff on every plan.

One tunnel per container. The wildcard approach on swagpipelineproxy is convenient but fragile. Dedicated tunnels mean one container’s problems don’t affect the others.

Timeline#

timeline title Cloudflare IaC Rollout 2026-05-02 : Terraform workspace created : yourdomain.tld imported : heezy.info and heezy.blog zones created : swagpipelineproxy tunnel imported 2026-05-14 : NS cutover at IONOS registrar : heezy-blog and heezy-info ECR repos created : Dashboard-managed tunnels deployed : Tunnel tokens stored in AWS Secrets Manager : k8s deployments live : heezy.blog and heezy.info serving traffic : Blog removed from SWAG container

Current State#

All three zones are in Terraform state. DNS records, tunnel configs, and zone settings are all code. The Cloudflare dashboard is now read-only. Each domain has its own dedicated tunnel and container, fully decoupled from the others.

Still pending: migrating swagpipelineproxy to a dashboard-managed tunnel and cleaning up the ConfigMap-based ingress config.

Cleanup#

With heezy.blog live and serving the same content, the blog was removed from the SWAG container entirely. The two are now completely decoupled.

Changes made:

  • Removed the Hugo build stage from dockerfiles/swag/Dockerfile — SWAG now serves only the www/ static site
  • Deleted dockerfiles/swag/blog-content/ from heezy-containers
  • Removed blog.subdomain.conf from the swag proxy confs ConfigMap in heezy-k8s
  • Removed the blog.subdomain.conf volumeMount from the swag deployment
  • Deleted the stale q-mcp/blog/ draft directory
  • Updated the blog content rules to point at dockerfiles/heezy-blog/ as the source of truth

blog.yourdomain.tld now returns 404 via the wildcard tunnel. The blog lives at heezy.blog.