> ## Content Index
> Fetch the complete content index at: https://www.diogomoleta.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Zero Trust as Code: Rebuilding a Cloudflare Security Lab with Terraform and CI
- URL: https://www.diogomoleta.com/zero-trust-as-code-rebuilding-a-cloudflare-security-lab-with-terraform-and-ci/
- Published: 2026-10-31T18:03:28.000Z
- Updated: 2026-10-31T18:03:28.000Z
- Author: Diogo Moleta

# 

This is the fourth lab in my defensive security series. The first three built a WAF, an outbound-only tunnel and a Zero Trust access layer by hand in the Cloudflare dashboard. Lab #4 rebuilds the same architecture as code with Terraform and adds a security pipeline on GitHub Actions, using free tiers only. This post walks through every step, including the mistakes, because most of what I learned came from them.

## 1\. The Technical Problem: Hand-Built Security Cannot Be Reproduced or Audited

A security setup clicked together in a dashboard works, but it has structural weaknesses:

- **Not reproducible:** nobody, including its author, can rebuild it exactly after an incident or a mistake.
- **Not reviewable:** there is no history of who changed which rule, or why.
- **Silent drift:** a manual change in the dashboard goes unnoticed until it causes a problem.
- **Settings and secrets scattered across screens:** tokens, IDs and policies live in places no scanner checks.

The goal of this lab was to turn the three-layer architecture into code that can be rebuilt with one command, reviewed in a pull request and checked automatically on every change.

## 2\. Solution Architecture

The architecture is unchanged from the manual version: three independent layers, so the failure or bypass of one does not expose the application.

```text
[ Client / Browser / Attacker ]
              │
              ▼
Layer 1: Edge WAF (Cloudflare)
  - Custom rule on the admin host
  - Blocks requests without the Sec-CH-UA header (HTTP 403)
              │
              ▼
Layer 2: Identity-Aware Proxy (Cloudflare Access)
  - Unauthenticated requests are redirected to login (HTTP 302)
  - Policy: one allowed e-mail, one-time PIN
  - 1 hour session, HttpOnly cookie
              │  (outbound-only encrypted tunnel)
              ▼
Layer 3: Origin server (Linux / WSL)
  - cloudflared daemon, no inbound ports open
  - nginx listening on 127.0.0.1 only
```

Terraform manages all three layers:

| File                                   | What it manages                                                                     |
| -------------------------------------- | ----------------------------------------------------------------------------------- |
| tunnel.tf                              | The tunnel, its ingress rules (with the mandatory catch-all) and the DNS records    |
| access.tf                              | A reusable Access policy and the self-hosted Access application                     |
| waf.tf                                 | The custom WAF ruleset                                                              |
| providers.tf, variables.tf, outputs.tf | Pinned provider versions, inputs and outputs (the tunnel token is marked sensitive) |

## 3\. Step-by-Step Implementation

### Step 1: Install the tools and create a least-privilege API token

Everything runs inside WSL (Ubuntu). Install Terraform from HashiCorp's apt repository, then the local checks:

```bash
sudo apt-get update && sudo apt-get install -y terraform
pipx install pre-commit
pipx install checkov
terraform -version
```

In the Cloudflare dashboard, create a custom API token scoped to one account and one zone, with only the permissions the project needs:

- Account: Cloudflare Tunnel: Edit
- Account: Access: Apps and Policies: Edit
- Zone: DNS: Edit
- Zone: Zone WAF: Edit
- Zone: Zone: Read

The provider reads the token from an environment variable, so it never appears in the code:

```bash
export CLOUDFLARE_API_TOKEN='<your-token>'
```

You also need the account ID and zone ID, shown on the domain's Overview page in the dashboard. A quick check that the token is valid:

```bash
curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  https://api.cloudflare.com/client/v4/user/tokens/verify
```

### Step 2: Pin the provider and declare the inputs

```hcl
# providers.tf
terraform {
  required_version = ">= 1.6"
  required_providers {
    cloudflare = { source = "cloudflare/cloudflare", version = "~> 5.0" }
    random     = { source = "hashicorp/random",     version = "~> 3.6" }
  }
}

provider "cloudflare" {}   # reads CLOUDFLARE_API_TOKEN
```

```hcl
# variables.tf
variable "account_id"  { type = string }
variable "zone_id"     { type = string }
variable "domain"      { type = string }
variable "admin_email" { type = string }
```

Real values go in `terraform.tfvars`, which is git-ignored. Only `terraform.tfvars.example`, with placeholders, is committed. Then run `terraform init`.

### Step 3: Build the tunnel (Layer 3)

The tunnel is remotely managed: its configuration lives in Cloudflare and is defined by Terraform, so the server only needs a token.

```hcl
# tunnel.tf
resource "random_id" "tunnel_secret" { byte_length = 32 }

resource "cloudflare_zero_trust_tunnel_cloudflared" "secops" {
  account_id    = var.account_id
  name          = "secops-tunnel-iac"
  config_src    = "cloudflare"
  tunnel_secret = random_id.tunnel_secret.b64_std
}

resource "cloudflare_zero_trust_tunnel_cloudflared_config" "secops" {
  account_id = var.account_id
  tunnel_id  = cloudflare_zero_trust_tunnel_cloudflared.secops.id
  config = {
    ingress = [
      { hostname = "tunnel.${var.domain}", service = "http://127.0.0.1:80" },
      { hostname = "admin.${var.domain}",  service = "http://127.0.0.1:8080" },
      { service = "http_status:404" }   # mandatory catch-all
    ]
  }
}

resource "cloudflare_dns_record" "app" {
  for_each = toset(["tunnel", "admin"])
  zone_id  = var.zone_id
  name     = each.key
  type     = "CNAME"
  content  = "${cloudflare_zero_trust_tunnel_cloudflared.secops.id}.cfargotunnel.com"
  proxied  = true
  ttl      = 1
}

data "cloudflare_zero_trust_tunnel_cloudflared_token" "secops" {
  account_id = var.account_id
  tunnel_id  = cloudflare_zero_trust_tunnel_cloudflared.secops.id
}
```

The last ingress rule is required: without a catch-all, cloudflared rejects the configuration. The tunnel token is exposed as a sensitive output so it never prints in plan or apply logs:

```hcl
# outputs.tf
output "tunnel_id" {
  value = cloudflare_zero_trust_tunnel_cloudflared.secops.id
}

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

### Step 4: Import what already exists

The DNS records already existed from the manual lab, so `apply` would fail with duplicates. Importing attaches the real objects to the code. Find the record IDs through the API, then import them:

```bash
curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=admin.example.com"

terraform import 'cloudflare_dns_record.app["admin"]' "$ZONE_ID/<record_id>"
terraform plan
```

The goal of every import is a plan that shows only the changes you intend. The ID format differs per resource, and the error messages tell you what is expected: `accounts/<account_id>/<app_id>` for an Access application, `zone/<zone_id>/<ruleset_id>` for a ruleset.

### Step 5: Add identity-aware access (Layer 2)

A reusable policy allows exactly one e-mail, and the application references it. Short sessions limit the damage from a stolen cookie.

```hcl
# access.tf
resource "cloudflare_zero_trust_access_policy" "admin_only" {
  account_id = var.account_id
  name       = "admin-only-email"
  decision   = "allow"
  include    = [{ email = { email = var.admin_email } }]
}

resource "cloudflare_zero_trust_access_application" "admin" {
  account_id       = var.account_id
  name             = "SecOps Admin Console"
  domain           = "admin.${var.domain}"
  type             = "self_hosted"
  session_duration = "1h"
  policies         = [{ id = cloudflare_zero_trust_access_policy.admin_only.id, precedence = 1 }]
}
```

An application for this hostname already existed, and Access refuses a second one for the same domain. Instead of deleting it, which would leave the endpoint unprotected for a few seconds, I imported it and let `terraform plan` show the differences: the policy swapped for the reusable one, the session shortened from 24h to 1h, and the cookie made HttpOnly. Only after reading that plan did I apply it.

### Step 6: Add the WAF rule (Layer 1)

```hcl
# waf.tf
resource "cloudflare_ruleset" "custom_waf" {
  zone_id = var.zone_id
  name    = "custom-waf"
  kind    = "zone"
  phase   = "http_request_firewall_custom"
  rules = [{
    action      = "block"
    description = "Block admin requests without Sec-CH-UA"
    enabled     = true
    expression  = "(http.host eq \"admin.${var.domain}\" and not any(http.request.headers.names[*] eq \"sec-ch-ua\"))"
  }]
}
```

A zone has one custom-rules ruleset per phase, and applying a new one can replace rules created by hand. Before applying, list the existing rulesets in that phase and import any that exist.

### Step 7: Apply and move the origin

```bash
terraform fmt && terraform validate
terraform plan -out=tfplan
terraform apply tfplan
```

The plan file contains state values, so it is git-ignored and deleted afterwards. On the origin, nginx serves a test page on the loopback interface only, and cloudflared connects outbound with the token:

```bash
terraform output -raw tunnel_token > ~/.secrets/tunnel_token && chmod 600 ~/.secrets/tunnel_token
TUNNEL_TOKEN="$(cat ~/.secrets/tunnel_token)" cloudflared tunnel --no-autoupdate run
```

A healthy start logs four `Registered tunnel connection` lines, and the tunnel shows as Healthy in the Zero Trust dashboard.

### Step 8: Keep secrets out of Git before the first commit

The repository is public, so this step comes before any push:

```text
# .gitignore
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!terraform.tfvars.example
backend.hcl
tfplan
*.tfplan
```

Local hooks block mistakes before they reach a commit:

```yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.109.4
    hooks: [{ id: terraform_fmt }, { id: terraform_validate }]
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.0
    hooks: [{ id: gitleaks }]
```

Before committing I ran `git status --short` to confirm that no `.tfvars`, `.tfstate` or plan file was staged, and searched the tracked files for my account IDs and e-mail address. The commit uses GitHub's private no-reply address, so my personal e-mail never appears in the public history.

### Step 9: Add the CI pipeline

The workflow needs no secrets because it never talks to Cloudflare: it only reads the code.

```yaml
# .github/workflows/ci.yml
name: ci
on:
  pull_request:
  push:
    branches: [main]
permissions:
  contents: read
jobs:
  terraform:
    name: fmt / validate / tflint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform fmt -check -recursive
      - run: terraform init -backend=false -input=false
      - run: terraform validate
      - uses: terraform-linters/setup-tflint@v4
      - run: tflint --init && tflint
  security:
    name: checkov / gitleaks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: pipx run checkov -d . --framework terraform --quiet --compact
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

Two details matter. `fetch-depth: 0` lets gitleaks scan the whole history instead of only the last commit. And pushing a workflow file needs a token with permission to update workflows, otherwise GitHub rejects the push.

### Step 10: Detect drift locally

Because the state is local (see the limitations below), drift detection is a script that compares the code with the real infrastructure:

```bash
#!/usr/bin/env bash
set -uo pipefail
cd "$(dirname "$0")"
terraform plan -detailed-exitcode -no-color -input=false > /tmp/drift-plan.txt 2>&1
case $? in
  0) echo "$(date -Is) no drift" ;;
  2) echo "$(date -Is) DRIFT detected, see /tmp/drift-plan.txt" ;;
  *) echo "$(date -Is) plan error, see /tmp/drift-plan.txt" ;;
esac
```

The exit code of `terraform plan -detailed-exitcode` is 0 for no changes, 1 for an error and 2 when the code and the real infrastructure differ.

## 4\. Practical Validation

Each layer was tested separately from the command line, then in the browser.

**Scenario A: request without Client Hints (plain curl)**

```bash
curl -sI https://admin.example.com | head -n1
```

Result: `HTTP/2 403`. The WAF blocked the request at Layer 1, before it reached Access.

**Scenario B: request with a browser-like Client Hints header**

```bash
curl -sI -H 'sec-ch-ua: "Chromium";v="130"' https://admin.example.com | grep -iE '^HTTP|^location'
```

Result: `HTTP/2 302` with a `location` header pointing to the Cloudflare Access login. Layer 1 let the request through, and Layer 2 redirected it because there was no valid session.

**Scenario C: authenticated browser access**

After entering the allowed e-mail and the one-time PIN in Chrome, Access issued a signed token and the tunnel delivered the page from the loopback-only nginx.

**Scenario D: the pipeline and drift detection**

- I committed a badly formatted Terraform file on a branch and opened a pull request. The `fmt` check failed within seconds: the pipeline caught the mistake, not me. I deleted the branch without merging.
- On a clean state, `drift-check.sh` reports no drift. Any manual change to a managed setting makes `terraform plan` exit with code 2, which the script reports as drift.

| Test vector                            | Response           | Layer that acted            | Result                                          |
| -------------------------------------- | ------------------ | --------------------------- | ----------------------------------------------- |
| Plain curl or bot                      | HTTP 403           | Layer 1: Edge WAF           | Blocked at the edge                             |
| Browser-like request, no session       | HTTP 302           | Layer 2: Access             | Redirected to login                             |
| Authenticated browser                  | HTTP 200           | Layer 3: tunnel to loopback | Access granted                                  |
| Direct access to the origin            | No public listener | Origin hardening            | Closed by design (not port-scanned in this lab) |
| Badly formatted code in a pull request | Failed check       | CI                          | Caught before merge                             |
| Manual change in the dashboard         | plan exit code 2   | drift-check.sh              | Reported as drift                               |

## 5\. What Went Wrong (and What It Taught Me)

Nearly every problem was a human one, not a technical one:

| Mistake                                      | What happened                                                                                                        | Lesson                                                                                          |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| A placeholder used as a value                | I exported the zone ID as the literal text from an example, so every API call failed with an unreadable parse error  | When something fails, print the variable before blaming the tool                                |
| An example e-mail left in the variables file | Had the policy been applied with it, only that address could log in and I would have been locked out of my own admin | A clean plan proves that code and reality agree, not that both are right. Check the real value  |
| Two Access applications for one domain       | Terraform wanted to create an application that already existed                                                       | Import the existing object and read the plan                                                    |
| One ruleset per phase                        | A new WAF ruleset could have replaced rules created by hand                                                          | List what exists before applying                                                                |
| A 502 after the cutover                      | The origin had been running in an ephemeral cloud shell that no longer existed                                       | A 502 means a connector is up but the origin is not answering. A 1033 means no connector at all |
| A process that seemed to die                 | The old cloudflared was writing its shutdown messages into the same log file the new one used                        | When the evidence contradicts your model, doubt the evidence first                              |
| A rejected push                              | The token had no permission to update workflow files                                                                 | Check the scopes a token needs before creating it                                               |

## 6\. Limitations

- **The `Sec-CH-UA` rule is noise reduction, not a security control.** Any attacker can forge the header, and Firefox and Safari do not send it, so they are blocked too. The real access control is Layer 2.
- **The Terraform state is local.** I chose no remote backend to keep the lab free and card-free. As a result there is no state locking, and CI does not run `plan` or `apply`: those run from my machine.
- **Drift detection is a local script,** not a scheduled workflow, for the same reason.
- **Checkov has limited coverage for the Cloudflare provider,** so its scan mostly proves that the pipeline works.
- **The origin only runs while my machine is on,** which is acceptable for a lab.

## 7\. Key Takeaways

- **Infrastructure as code turns a lab into evidence.** The architecture is now reproducible, reviewable and checked on every change.
- **Import teaches more than create.** It forces you to understand how Terraform compares code with the real world.
- **A pipeline you have never seen fail is not trustworthy.** Break it on purpose once.
- **Security fails at the human link.** Automation does not remove human error; it makes it visible earlier.

### Next steps

- Refactor into modules for the tunnel, access and WAF layers
- Move the state to a remote backend and schedule drift detection
- Add policy as code to block plans that violate security rules
- Repeat the pattern on AWS (networking, IAM and threat detection)

The full code, diagram and pipeline screenshots are in the repository: https://github.com/Day-hub-lab/zero-trust-as-code