Introduction

In Part 1 , we demonstrated how Terraform can streamline reproducible security configurations. In this follow-up, I’ll show how to extend those principles across AWS, Azure, and GCP using Cloudflare Zero Trust. You’ll see how the project’s modular structure, automation, and dynamic routing reduce manual security tasks by up to 80%—based on my own benchmarks.

What’s new since Part 1:

  • Custom subnets and improved network segmentation
  • Automated device profiles and dynamic WARP routing
  • Expanded multi-cloud support with updated diagrams
  • Terraform code is now 4100+ lines of code, 87 files and 21 directories (even if the quantity does not mean quality!) with 143 resources

Let’s dive into the updated architecture and key modules powering this environment.


Architecture Breakdown

Architecture diagram (updated 19/05/2025)

Key Components

  1. Automation and code versioning: Github, Terraform and VSCode
  2. Cross-cloud tunnel setups: GCP, AWS and Azure
  3. Multi-OS Cloudflare Agent setup: Cloudflare WARP
  4. Identity provider configurations: Okta, Azure AD (Entra ID) and Google
  5. Cloudflare Zero-trust Platform
  6. Security Policies: centralized management including posture, group membership, etc…
  7. SaaS app integrations (Meraki, Salesforce, etc…)
  8. Access App: Cloudflare AppLauncher
  9. Observability: Datadog

The setup integrates automation (GitHub, Terraform), cross-cloud tunnels (AWS, Azure, GCP), multi-OS Cloudflare WARP agents, multiple identity providers (Okta, Azure AD, Google), centralized security policies, SaaS app integrations, Cloudflare AppLauncher, and observability with Datadog.

Now that we have a high-level sense of what components are part of the project, let us delve into the code.

Project Structure Overview

A robust automation project demands not just effective code, but a clear, maintainable structure. From the outset, this Terraform repository was designed for modularity, reusability, and collaboration across teams.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
├── modules/
   ├── azure/        # Azure AD
   ├── cloudflare/
   ├── keys/
   ├── okta/
   └── warp-routing/ # Dynamic subnet calculation
├── scripts/
├── doc/              # documentation
├── variables.tf
├── main.tf
├── outputs.tf
├── provider.tf
├── vm-aws-instance.tf
├── vm-azure-instance.tf
├── vm-gcp-instance.tf
└── ...

Why This Structure?

  • Separation of Concerns: Each cloud provider and major function (identity, networking, security) is isolated in its own module. This keeps codebases clean, reduces merge conflicts, and accelerates onboarding for new contributors.
  • Reusability: Modules can be reused across environments (dev, staging, prod) or even in other projects, simply by adjusting variables and wiring.
  • Scalability: As the environment grows-adding more SaaS integrations, tunnels, or regions-the structure supports incremental change without major refactoring.
  • Documentation-Driven: The /doc directory contains up-to-date architecture diagrams and dependency graphs, ensuring that the “why” behind each component is as accessible as the “how.” (contains excalidraw diagram as well as mermaid graph)

N.B.: I have decided to focus this blog post on two modules, namely cloudflare module and warp-routing modules and also talk a bit about the VM creation because they are at the heart of the project for the first one and the second one is a neat way to deal with routing (more on that later)


Before initializing your Terraform project, you need a good way to store the different API keys that we are going to be using.

Environment variables

All the API keys and secret are stored in environment variable. More over, I am using direnv to have environment variables pertaining to this particular project loaded when I browse to the project folder. It is a very neat way to declutter your .profile.

Advantages of using environment variables

  • Avoids Hardcoding Sensitive Data: Storing API keys directly in Terraform configuration files or version control exposes them to anyone with access to those files. Environment variables keep secrets out of source code, reducing the risk of accidental leaks.
  • Obfuscation: Environment variables help obfuscate sensitive values, making it harder for unauthorized users to access API keys just by reading configuration files.
  • Integration with CI/CD: Environment variables are easily managed in continuous integration and deployment pipelines, where secrets can be injected securely at runtime without being stored in code repositories.
  • Terraform Variable Precedence: Terraform supports passing variable values through environment variables using the TF_VAR_ prefix (e.g., TF_VAR_api_key). This method is prioritized after CLI arguments and .tfvars files, but before prompting the user, making it a robust and convenient option for secret injection.
  • Seamless Environment Switching: Environment variables allow you to easily switch between development, staging, and production environments without modifying configuration files or maintaining separate versions for each environment.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Terraform Zero Trust Project
# Cloudflare
export TF_VAR_cloudflare_api_key=""
export TF_VAR_cloudflare_account_id=""
export TF_VAR_cloudflare_email=""
export TF_VAR_cloudflare_zone_id=""

# AWS
export AWS_ACCESS_KEY_ID=""
export AWS_SECRET_ACCESS_KEY=""

# Azure
export TF_VAR_azure_tenant_id=""
export TF_VAR_azure_subscription_id=""

# Google
export GOOGLE_APPLICATION_CREDENTIALS="path_to_your_json_file"
export TF_VAR_gcp_project_id=""

# Okta
export TF_VAR_okta_org_name=""
export TF_VAR_okta_api_token=""

# Datadog
export TF_VAR_datadog_api_key=""

Key Take-aways

  • All sensitive API keys and secrets are stored as environment variables, managed via direnv for project-specific loading.
  • This approach avoids hardcoding secrets, integrates well with CI/CD, supports seamless environment switching, and leverages Terraform’s variable precedence for secure secret injection.

Now that we have our environment variables defined, let’s see how we programmatically declare our workloads (VMs).


VM Creation: Secure provisioning across Cloud

This section details how VMs are securely provisioned in AWS, Azure, and GCP using Terraform, with a focus on SSH key management, cloud-init automation, and least-privilege networking.

SSH Key Generation & Injection

Terraform programmatically generates and injects SSH keys using provider-specific methods while avoiding hardcoded secrets:

AWS Example (vm-aws-instance.tf)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Centralized SSH key module manages key pairs
module "ssh_keys" {
  source = "./modules/keys"
}

# AWS key pair resource
resource "aws_key_pair" "aws_ec2_service_key_pair" {
  key_name   = "aws_ssh_service"
  public_key = module.ssh_keys.aws_ssh_service_public_key # Centralized key from module
}

Cloud-Init Templating

Cloud-init configurations are dynamically populated using Terraform variables for cross-cloud consistency:

AWS Cloudflared Init (scripts/aws-cloudflared-init.yaml):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#cloud-config
hostname: ${hostname}
package_update: true
package_upgrade: true

packages:
  - wget
  - curl
  - traceroute
  - build-essential
  - hping3
  - net-tools
  - unzip

runcmd:
  - sudo mkdir -p --mode=0755 /usr/share/keyrings
  - curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
  - echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
  - sudo apt-get update && sudo apt-get install cloudflared
  - sudo cloudflared service install ${tunnel_secret_aws}
  - sudo timedatectl set-timezone Europe/Paris

# Datadog Agent installation
  - 'echo "DD_API_KEY=${datadog_api_key} DD_SITE=${datadog_region}" > /tmp/dd_env.log'
  - 'DD_API_KEY=${datadog_api_key} DD_SITE=${datadog_region} bash -c "$(curl -L https://install.datadoghq.com/scripts/install_script_agent7.sh)" > /tmp/dd_install.log 2>&1'

Terraform Variable Injection:

1
2
3
4
5
6
user_data = templatefile("${path.module}/scripts/aws-cloudflared-init.yaml", {
    hostname          = "${var.aws_ec2_cloudflared_name}-${count.index}"
    tunnel_secret_aws = module.cloudflare.aws_extracted_token
    datadog_api_key   = var.datadog_api_key
    datadog_region    = var.datadog_region
})

This ensures secure secret handling while maintaining reusable templates.

Security Group/Firewall Rules

Zero-trust networking is enforced through provider-specific security configurations:

CloudSSH AccessICMPEgressUnique Feature
AWSRestricted to user IP + Cloudflared SGAllowed from user IPFull outboundLayered SG for tunnel/service separation
AzureNSG rules limited to user IPRestricted to user IPFull outboundWarp connector VM with custom init
GCPFirewall rule with target tagsRestricted to user IPBlock SSH egressEphemeral instances via preemptible scheduling

GCP Firewall Example (vm-gcp-instance.tf):

1
2
3
4
5
6
7
8
resource "google_compute_firewall" "allow_ssh_from_my_ip" {
  allow {
    protocol = "tcp"
    ports    = ["22"]
  }
  source_ranges = ["${data.http.my_ip.response_body}/32"] # Dynamic IP restriction
  target_tags   = ["ssh-cf-tunnel-only"] # Tag-based enforcement
}

Critical Design Choice: All providers block default SSH access except from the user’s current IP and authorized tunnel components

Cross-Cloud Consistency

The implementation achieves security parity through:

  1. Centralized SSH Key Modules
  2. Dynamic Cloud-Init Templating
  3. IP Restriction Patterns
  4. Tag-Based Firewalling

This ensures identical security posture whether deploying to AWS, Azure, or GCP while respecting each provider’s native tooling.

Key Take-aways

  • VMs are provisioned in AWS, Azure, and GCP with the necessary SSH keys and required agents/software installed at boot.
  • This enables secure, automated access and management across all cloud environments.

Now let us dive into the Cloudflare module that powers the whole setup.


Cloudflare Module

This is the cloudflare module structure with terraform files being named to be self explanatory (I hope):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
└── cloudflare
    ├── cloudflare-app-policies.tf
    ├── cloudflare-apps.tf
    ├── cloudflare-gateway-policy.tf
    ├── cloudflare-tags.tf
    ├── cloudflared-tunnel-main.tf
    ├── device-profiles.tf
    ├── dns-records.tf
    ├── outputs.tf
    ├── provider.tf
    ├── rule-groups.tf
    ├── scripts
       └── latest_osx_version_posture.sh
    ├── short-lived-certificate.tf
    └── variables.tf

If we have a look at cloudflared-tunnel-main.tf, the creation of the tunnel is straight forward:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#=====================================
# GCP Tunnel
#=====================================
resource "cloudflare_zero_trust_tunnel_cloudflared" "gcp_cloudflared_tunnel" {
  account_id = var.cloudflare_account_id
  name       = var.cf_gcp_tunnel_name
  config_src = "cloudflare"
}

data "cloudflare_zero_trust_tunnel_cloudflared_token" "gcp_tunnel_cloudflared_token" {
  account_id = var.cloudflare_account_id
  tunnel_id  = cloudflare_zero_trust_tunnel_cloudflared.gcp_cloudflared_tunnel.id
}

We retrieve the token for authentication so that it can be passed on to the VM to initiate the connection towards Cloudflare. Now that we’ve defined the tunnel, let’s see how authentication is handled.

As part of the SSH Access for Infrastructure use case, we need to generate a Cloudflare SSH Certificate Authority (CA) and this can be done (currently) only via API. I have integrated this API call in the Terraform code and store it into a “local” called gateway_ca_certificate that is, in turns, passed on to on output to be consumed elsewhere. This is very handy because we need this as part of the initialization script for the VM supporting the SSH Infrastructure Access use case.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#======================================================
# Short lived Certificate CA for Infrastructure Access
#======================================================
locals {
  gateway_ca_certificate = jsondecode(data.http.short_lived_cloudflare_ssh_ca.response_body)
}

data "http" "short_lived_cloudflare_ssh_ca" {
  url = "https://api.cloudflare.com/client/v4/accounts/${var.cloudflare_account_id}/access/gateway_ca"

  request_headers = {
    "X-Auth-Email" = var.cloudflare_email
    "X-Auth-Key"   = var.cloudflare_api_key
    "Content-Type" = "application/json"
  }
}

[...]

output "gateway_ca_certificate" {
  description = "The Cloudflare Gateway CA certificate"
  value       = local.gateway_ca_certificate.result.public_key
  sensitive   = true
}

Then let see what the Infrastructure App looks like in Terraform since this is the most interesting use case here.

Infrastructure Access Application

First, we define the Target and the Application (type = “infrastructure”)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#===============================
# Access for Infrastructure App
#===============================
# Creating the Target
resource "cloudflare_zero_trust_access_infrastructure_target" "ssh-gcp-instance" {
  account_id = var.cloudflare_account_id
  hostname   = var.cf_target_name
  ip = {
    ipv4 = {
      ip_addr = var.gcp_vm_internal_ip
    }
  }
}

# Creating the infrastructure Application
resource "cloudflare_zero_trust_access_application" "ssh_gcp_infrastructure" {
  account_id       = var.cloudflare_account_id
  type             = "infrastructure"
  name             = var.cf_infra_app_name
  logo_url         = "https://upload.wikimedia.org/wikipedia/commons/0/01/Google-cloud-platform.svg"
  tags             = [cloudflare_zero_trust_access_tag.zero_trust_demo_tag.name]
  session_duration = "1h"

  target_criteria = [{
    port     = "22",
    protocol = "SSH"
    target_attributes = {
      hostname = [var.cf_target_name]
    },
  }]
  policies = [{ ...see below }]

}

We associate the target with var.gcp_vm_internal_ip which represents the private IP address of the GCP VM. Then we specify the port (22) and the protocol (SSH).

Once we have done the definition of Application, we need to define a policy to access it.

Policy Definition

This is how we define a policy for the Infrastructure application (“ssh-gcp-instance”).

  1. You will be able to access the app if you meet one of the following criteria (include block): (1) you belong to saml group okta_infrastructureadmin_saml_group_name or (2) you belong to saml group okta_contractors_saml_group_name or (3) you have an email associated with domain cf_email_domain.
  2. You will require (require block) to: (1) have the WARP Client in Gateway mode (device_posture = {integration_uid = var.cf_gateway_posture_id}) (2) use an authentication method including “MFA” (3) use an authentication method excluding “SMS”

The cf_email_domain is useful, especially if you have contractors which do not have a user definition in your Identity Provider (in this example Okta)

Finally you setup a connection_rule. In the example below, I allow users to ssh into the machine with their email prefix (e.g. my email is bob@macharpe.com , then I can login as “bob”)


N.B.: Cloudflare will not create new users on the target. UNIX users must already be present on the server (source )


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
policies = [{
    name     = "SSH GCP Infrastructure Policy"
    decision = "allow"

    allowed_idps                = [var.cf_okta_identity_provider_id]
    auto_redirect_to_identity   = true
    allow_authenticate_via_warp = false

    include = [
      {
        saml = {
          identity_provider_id = var.cf_okta_identity_provider_id
          attribute_name       = "groups"
          attribute_value      = var.okta_infrastructureadmin_saml_group_name
        }
      },
      {
        saml = {
          identity_provider_id = var.cf_okta_identity_provider_id
          attribute_name       = "groups"
          attribute_value      = var.okta_contractors_saml_group_name
        }
      },
      {
        email_domain = {
          domain = var.cf_email_domain
        }
      }
    ]

    require = [
      {
        device_posture = {
          integration_uid = var.cf_gateway_posture_id
        }
      },
      {
        auth_method = {
          auth_method = "mfa"
        }
      }
    ]

    exclude = [
      {
        auth_method = {
          auth_method = "sms"
        }
      }
    ]

    connection_rules = {
      ssh = {
        allow_email_alias = true
        usernames         = [] # None
      }
    }
}]

N.B.: there is currently a technical limitation, Infrastructure Access Application do not support “reusable policy” and therefore this policy is defined within the app definition.


Policy configuration view from the UI

Talking about a reusable policy (which can be applied to as many application as you want), here is an example of the Terraform definition of one (e.g. Salesforce Policy)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#=======================
# POLICY for Salesforce
#=======================
resource "cloudflare_zero_trust_access_policy" "salesforce_policy" {
  account_id       = var.cloudflare_account_id
  decision         = "allow"
  name             = "Salesforce Policy"
  session_duration = "0s"

  include = [
    {
      group = {
        id = cloudflare_zero_trust_access_group.sales_rule_group.id
      }
    },
    {
      group = {
        id = cloudflare_zero_trust_access_group.sales_engineering_rule_group.id
      }
    }
  ]

  require = [
    {
      device_posture = {
        integration_uid = var.cf_gateway_posture_id
      }
    },
    {
      group = {
        id = cloudflare_zero_trust_access_group.country_requirements_rule_group.id
      }
    },
    {
      group = {
        id = cloudflare_zero_trust_access_group.latest_os_version_requirements_rule_group.id
      }
    },
    {
      auth_method = {
        auth_method = "mfa"
      }
    }
  ]
}

In the particular policy we make use of “Rule Groups”: country_requirements_rule_group and latest_os_version_requirements_rule_group

The first one sets the location from which you can access the application (include) but also countries from which you may not login (exclude). Below is the code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
resource "cloudflare_zero_trust_access_group" "country_requirements_rule_group" {
  account_id = var.cloudflare_account_id
  name       = "Country Requirements"

  include = [
    {
      geo = {
        country_code = "FR"
      }
    },
    {
      geo = {
        country_code = "DE"
      }
    },
    {
      geo = {
        country_code = "US"
      }
    },
    {
      geo = {
        country_code = "GB"
      }
    }
  ]
  exclude = [
    {
      geo = {
        country_code = "CN"
      }
    },
    {
      geo = {
        country_code = "RU"
      }
    }
  ]
}

Key Take-aways

  • The Cloudflare module manages Zero Trust tunnels, device profiles, DNS records, policies, and short-lived SSH CA certificates via Terraform.
  • Infrastructure Access Applications are defined for secure SSH access, with policies enforcing group membership, device posture, and MFA.
  • Policies can be application-specific or reusable (e.g., for Salesforce), and leverage rule groups for granular access control (such as country or OS version restrictions.

One thing needs a bit more explanation here is how do we programmatically defined the routes in the WARP client so that connections to workloads are routed through Cloudflare, not locally? This is what the warp-routing module is designed for.


warp-routing module

The warp-routing module is probably the smartest one of the setup. Essentially, it consists of 3 python scripts:

1
2
3
4
5
6
└── modules
    └── warp-routing
           └── scripts
               ├── generate_subnets_aws.py
               ├── generate_subnets_azure.py
               └── generate_subnets_gcp.py

These scripts were inspired by Cloudflare documentation itself (source ), there is a tool to calculate which subnets to exclude.

  1. The script gets a Private Subnet (e.g. 10.156.70.0/24) as an input.
  2. The script infers the corresponding RFC1918 subnet to which the input belongs (e.g. 10.156.70.0/24 belongs to 10.0.0.0/8)
  3. The script calculates all the subnets belonging to the base_network (e.g. 10.0.0.0/8) but excluding the subnet that we have as an input (e.g. 10.156.70.0/24)

This will turn out to be very useful to programmatically update the routes of the WARP clients to make sure that this subnet (e.g. 10.156.70.0/24) is not routed locally but instead sent to Cloudflare so it can eventually reach the VM.


N.B: by default Cloudflare exclude all RFC1918 networks from being routed through the WARP Client.


Here is a snippet of the output file generated in json format:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
  "metadata": {
    "generated_at": "2025-05-19T19:05:19.088335",
    "script_version": "1.1",
    "input_received": "10.156.70.0/24",
    "normalized_exclusion": "10.156.70.0/24",
    "base_network": "10.0.0.0/8"
  },
  "exclusions": [
    {
      "address": "10.0.0.0/9",
      "description": "GCP Excluded Subnet 1",
      "type": "calculated"
    },
    {
      "address": "10.192.0.0/10",
      "description": "GCP Excluded Subnet 2",
      "type": "calculated"
    },
    {
      "address": "10.160.0.0/11",
      "description": "GCP Excluded Subnet 3",
      "type": "calculated"
    },

    [...]

  ],
  "validation": {
    "rfc1918_compliant": true,
    "complete_coverage": true
  }
}

warp_routing_subnets_calculation.tf

In Terraform, I execute the script and I, then, declare a resource “local_file” that I will be able to reuse elsewhere. This is the terraform file warp_routing_subnets_calculation.tf calling these scripts (example for AWS):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#=============================================
# SUBNET CALCULATION FOR WARP ROUTING AWS VMs
#=============================================
resource "null_resource" "python_script_gcp_infrastructure" {
  provisioner "local-exec" {
    command = "python3 ${path.root}/modules/warp-routing/scripts/generate_subnets_gcp.py ${var.gcp_ip_cidr_range}"
  }

  triggers = {
    script_hash = filesha256("${path.module}/scripts/generate_subnets_gcp.py")
  }
}

data "local_file" "gcp_subnet_output" {
  filename = "${path.root}/modules/warp-routing/output/warp_subnets_including_all_except_gcp_internal_subnet.json"

  depends_on = [null_resource.python_script_gcp_infrastructure]
}

Now that we have generated the json file, let’s see how we are making use of it to define custom device profiles

device-profiles.tf

In the device-profiles.tf located in module/cloudflare, we need to

  1. Read the default_profile so that we can retrieve the default excluded routers
  2. Define where the json generated files are located
  3. Ensure that the Terraform does not intend to read them before the scripts have run (this is done via: depends_on = [var.cf_aws/gcp/azure_json_subnet_generation])
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
data "cloudflare_zero_trust_device_default_profile" "main" {
  account_id = var.cloudflare_account_id
}

data "local_file" "azure_config" {
  filename   = "${path.root}/modules/warp-routing/output/warp_subnets_including_all_except_azure_internal_subnet.json"
  depends_on = [var.cf_azure_json_subnet_generation] # Reference the null_resource from the warp-routing module
}

data "local_file" "gcp_config" {
  filename   = "${path.root}/modules/warp-routing/output/warp_subnets_including_all_except_gcp_internal_subnet.json"
  depends_on = [var.cf_gcp_json_subnet_generation] # Reference the null_resource from the warp-routing module
}

data "local_file" "aws_config" {
  filename   = "${path.root}/modules/warp-routing/output/warp_subnets_including_all_except_aws_internal_subnet.json"
  depends_on = [var.cf_aws_json_subnet_generation] # Reference the null_resource from the warp-routing module
}

Once that’s done we define a local {} which is going to build the final_exclude_routes which is going to:

  1. Exclude all default RFC1918 subnets which have been infered by the script
  2. Include all the routes in these RFC1918 subnets except the ones to which the different VMs belong to.

This is achieved via for loops and here is the final_exclude_routes.

1
2
3
4
5
6
7
8
# Final merged configuration
final_exclude_routes = merge(
    local.default_routes,  # Base routes
    local.azure_routes,    # Azure exceptions
    local.gcp_routes,      # GCP exceptions
    local.aws_routes,      # AWS exceptions
    local.custom_cgnat_map # Custom CGNAT ranges
)

Now that we have the final_exclude_routes, we can use it in the definition of custom device profiles as per the below example (I have purposefully remove some part of the definition for readability

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#==============================
# Customized profile for WARP
#==============================
resource "cloudflare_zero_trust_device_custom_profile" "client_custom_route_profile" {
  account_id = var.cloudflare_account_id
  enabled    = true

  name                  = "Zero-Trust demo local laptop (mac)"
  description           = "This profile is for the local laptop (running macos) for my zero-trust demo"
  precedence            = random_integer.client_precedence.result
  match                 = "os.name == \"${var.cf_device_os}\""

  [...]

  support_url           = "Zero-TrustDemo-LaptopProfile"

  # Exclude routes configuration
  exclude = [for route in values(local.final_exclude_routes) : {
    address     = route.address
    description = route.description
  }]

  [...]

}

In the exclude section we use a for loop to circle through the final_exclude_routes


N.B.1: you have noted that the support_url is equal to “Zero-TrustDemo-LaptopProfile”. This is very handy to check which profile is applied to your device (I used the tip shared here ).

Essentially you can issue “warp-cli settings support-url” to know which profile is being applied (example below on my local laptop)

1
2
macharpe@macharpe-mac:~  % warp-cli settings support-url
macOSProfile

N.B.2: To set the precedence, I have used a random integer between 0 and 99 to ensure that these profiles will super-seed whatever custom device profil was created


Once you have run the Terraform code, you will see 3 new device profiles under Settings > Warp Client > Device Settings:

Device profiles created via Terraform

And you can look at the Split Tunnels section > Exclude IPs and domains > Manage to see the results

Warp Exclude IPs and domains 1

Warp Exclude IPs and domains 2

Key Take-aways

  • The warp-routing module uses Python scripts to dynamically calculate subnet exclusions for each cloud, ensuring only desired private IP ranges are routed through Cloudflare WARP.
  • This allows programmatic, accurate split-tunneling configuration, improving security and connectivity.
  • Device profiles are created in Terraform to enforce these routing rules, with precedence set to ensure they override other profiles.

We have covered quite a bit of ground so far, let us see what it looks like in action.


In Action

This is a video showing the full terraform apply and some screenshots

Networks > Tunnels

Access > Applications

Final Output

This is what the final output looks like. It makes it easy to ssh into the different workloads. Plus it gives you some information about the tunnel status and version.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
Apply complete! Resources: 143 added, 0 changed, 0 destroyed.

Outputs:

AWS_EC2_INSTANCES = [
  {
    "internal_ip" = "172.16.69.72"
    "name" = "cloudflared-replica-aws-0"
    "public_ip" = "3.75.62.84"
    "ssh" = "ssh ubuntu@172.16.69.72 -i modules/keys/out/aws_ssh_cloudflared_key_pair_0"
    "tunnel" = {
      "cf_tunnel_id" = "8ebd931c-019b-4e42-bace-30a807ce3413"
      "cf_tunnel_status" = "healthy"
      "cf_tunnel_version" = "2025.5.0"
    }
  },
  {
    "internal_ip" = "172.16.69.78"
    "name" = "cloudflare-zero-trust-demo-aws"
    "public_ip" = "3.75.62.84"
    "ssh" = "ssh ubuntu@172.16.69.78 -i modules/keys/out/aws_ssh_service_key_pair"
  },
]
AZURE_VMS = {
  "cloudflare-warp-connector-azure-0" = {
    "internal_ip" = "192.168.71.4"
    "public_dns" = "cloudflare-warp-connector-azure-0.westeurope.cloudapp.azure.com"
    "public_ip" = "72.144.30.23"
    "ssh" = "ssh az-admin@72.144.30.23 -i modules/keys/out/azure_ssh_key_pair_0"
  }
  "cloudflare-zero-trust-demo-azure-1" = {
    "internal_ip" = "192.168.71.5"
    "public_dns" = "cloudflare-zero-trust-demo-azure-1.westeurope.cloudapp.azure.com"
    "public_ip" = "135.220.17.236"
    "ssh" = "ssh az-admin@135.220.17.236 -i modules/keys/out/azure_ssh_key_pair_1"
  }
}
GCP_COMPUTE_INSTANCES = [
  {
    "internal_ip" = "10.156.70.2"
    "name" = "cloudflare-infrastructure-access-gcp"
    "public_ip" = "34.159.92.67"
    "tunnel" = {
      "cf_tunnel_id" = "901c7e10-5352-4e0d-bed8-7c4493b04d07"
      "cf_tunnel_status" = "healthy"
      "cf_tunnel_version" = "2025.5.0"
    }
  },
  {
    "internal_ip" = "10.156.70.4"
    "name" = "cloudflare-zero-trust-demo-gcp-0"
    "public_ip" = "35.234.78.229"
    "ssh" = "ssh ubuntu@35.234.78.229 -i modules/keys/out/gcp_vm_key_pair_0"
  },
  {
    "internal_ip" = "10.156.70.3"
    "name" = "cloudflare-zero-trust-demo-gcp-1"
    "public_ip" = "34.40.56.2"
    "ssh" = "ssh ubuntu@34.40.56.2 -i modules/keys/out/gcp_vm_key_pair_1"
  },
]
MY_IP = {
  "IPv4" = "xxx.xxx.xxx.xxx" (obfuscated)
}
SSH_COMMAND = {
  "bob" = "ssh bob@10.156.70.2"
  "jose" = "ssh jose@10.156.70.2"
  "matthieu" = "ssh matthieu@10.156.70.2"
}

Conclusion

This project demonstrates how to build a production-ready Zero Trust environment across multiple clouds using Terraform. Key takeaways:

  1. Security First Least privilege access + short-lived credentials + MFA enforcement
  2. Modularity Wins Provider-specific modules enable easy cross-cloud expansion
  3. Documentation Matters Clear architecture diagrams and variable descriptions accelerated onboarding
  4. Automate Everything Cloud-init scripts and Terraform modules reduced manual configuration errors

The complete codebase and documentation are available in the project repository. For a hands-on demo, deploy the environment using the provided terraform.tfvars.example as a template.

Lessons Learned

  • Multi-Cloud Complexity Maintaining consistent security policies across AWS/Azure/GCP required careful coordination of security group rules and IAM roles.
  • Zero Trust Tradeoffs While Cloudflare’s device posture checks add security, they introduced initial complexity in tunnel token management.
  • Terraform Limitations Azure Warp Connector required manual UI setup due to Terraform provider limitations, highlighting the importance of hybrid automation approaches.
  • Testing Challenges Implementing terraform apply rollbacks for failed multi-cloud deployments required careful state file management.

Technical challenges

  • I am still facing some technical challenges with the custom device profile resource in Terraform, probably due to Cloudflare API. Below is the error I get when I “terraform apply” for the second time. It looks close to this and this .
1
[{"code":2004,"message":"bad device request"}]
  • I have not found a way to retrieve the CGNAT (Carried-Grade NAT) IP assigned to WARP Client when you have enable Override local interface IP in Cloudflare UI.
  • I am still facing challenges while using Microsoft Azure API (specifically while destroying the setup: terraform destroy).
  • There is currently no terraform resource to create a WARP Connector tunnel which is expected as this is in beta.

Roadmap

  1. Use the Entra ID integration
  2. Use case for WARP Connector (Site-to-Site, Site-to-Internet…) link to the documentation
  3. SaaS Application in Cloudflare Access managed by Terraform
  4. Observability use case with Datadog

What’s Next

Looking ahead, Part 3 will explore advanced use cases you can demonstrate with this environment.

👉 Follow me on GitHub to get notified when I release the code: https://github.com/macharpe

This isn’t just a demo-it’s a blueprint for modern security. Stay tuned to transform your Zero Trust strategy from concept to reality. 🔒


This is Part 2 of a 3-part series on building scalable Zero Trust demo environments.