Zero Trust as Code: Rebuilding a Cloudflare Security Lab with Terraform and CI
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.
[ 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 onlyTerraform manages all three layers:
File | What it manages |
|---|---|
| The tunnel, its ingress rules (with the mandatory catch-all) and the DNS records |
| A reusable Access policy and the self-hosted Access application |
| The custom WAF ruleset |
| 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:
sudo apt-get update && sudo apt-get install -y terraform
pipx install pre-commit
pipx install checkov
terraform -versionIn 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:
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:
curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
https://api.cloudflare.com/client/v4/user/tokens/verifyStep 2: Pin the provider and declare the inputs
# 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# 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.
# 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:
# 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:
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 planThe 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.
# 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)
# 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
terraform fmt && terraform validate
terraform plan -out=tfplan
terraform apply tfplanThe 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:
terraform output -raw tunnel_token > ~/.secrets/tunnel_token && chmod 600 ~/.secrets/tunnel_token
TUNNEL_TOKEN="$(cat ~/.secrets/tunnel_token)" cloudflared tunnel --no-autoupdate runA 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:
# .gitignore
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!terraform.tfvars.example
backend.hcl
tfplan
*.tfplanLocal hooks block mistakes before they reach a commit:
# .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.
# .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:
#!/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" ;;
esacThe 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)
curl -sI https://admin.example.com | head -n1Result: 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
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
fmtcheck failed within seconds: the pipeline caught the mistake, not me. I deleted the branch without merging. - On a clean state,
drift-check.shreports no drift. Any manual change to a managed setting makesterraform planexit 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-UArule 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
planorapply: 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