Cloudflare Tunnels as Code: Moving Past the cert.pem Era
Cloudflare Tunnel has long been a solid way to expose internal services through Cloudflare’s edge without opening inbound ports. The catch was operational overhead: getting a tunnel running meant downloading the cloudflared binary, running cloudflared tunnel login, and manually handling a cert.pem file for authentication.
Named Tunnels changed that model. With a supported API endpoint and a JSON-based credentials file for the origin side, the whole lifecycle is now automatable. That opens the door to treating tunnels as a first-class resource in Terraform, deployable alongside the applications and infrastructure they serve.
Why Automate Tunnel Creation
Being able to generate a tunnel dynamically has practical knock-on effects for teams operating at scale:
- More of the Cloudflare configuration lives in code, alongside origin infrastructure.
- Auto-scaling and ephemeral resource pools become viable because tunnel provisioning no longer depends on a human step.
- Temporary resources like bastion hosts can be spun up and torn down without leaving orphaned tunnel configs.
Traffic bound for a Named Tunnel only reaches zones within the same Cloudflare account, which keeps exposure scoped. And since IP addresses are increasingly short-lived, tying services to a tunnel gives you a stable target that is independent of the origin’s network address.
Terraform is a logical fit here because the Cloudflare provider is actively maintained. The same work could be done by calling the API directly from another tool, but Terraform lets you manage the Cloudflare side, the compute instance, and the server bootstrap in one pass.
Reference Deployment Layout
The reference setup tracks a single Google Compute Engine instance that hosts two services behind one tunnel: an HTTPbin container on port 8080 and the local SSH daemon. The same concepts apply whether the origin is on-prem, in a single cloud, or distributed across multiple providers.
Cloudflare Tunnel Ingress Rules let one tunnel carry traffic to several local endpoints. Here, the rules route one hostname to HTTPbin and another to SSH. An additional benefit: the SSH hostname can sit behind a Cloudflare Access Zero Trust policy.
The example uses Terraform 0.15.0, though tunnels are compatible with any Terraform running version 0.13 or later.
cdlg at cloudflare in ~/Documents/terraform/blog on master
$ terraform --version
Terraform v0.15.0
on darwin_amd64
+ provider registry.terraform.io/cloudflare/cloudflare v2.18.0
+ provider registry.terraform.io/hashicorp/google v3.56.0
+ provider registry.terraform.io/hashicorp/random v3.0.1
+ provider registry.terraform.io/hashicorp/template v2.2.0
The configuration is split across a few .tf files by purpose — for instance, instance.tf holds only the GCP server resources and the DNS records pointing at the tunnel. This is a matter of taste rather than necessity. Variables such as var.cloudflare_zone are filled from a terraform.tfvars file, which keeps the whole configuration reusable as a template for other deployments.
If you store credentials in a .tfvars file rather than environment variables, make sure your version control ignores it. In the example repository, a .gitignore entry excludes terraform.tfvars; you copy terraform.tfvars.example to that name and fill in your own values before running.
Defining the Tunnel Resource
The core Cloudflare-side resource is compact. The cloudflare_argo_tunnel takes the account ID, a tunnel name, and a secret. The secret is generated by a separate random_id resource rather than being hardcoded, and Terraform regenerates it on each run. The secret must be base64 standard encoded and at least 32 characters long.
resource "random_id" "argo_secret" {
byte_length = 35
}
resource "cloudflare_argo_tunnel" "auto_tunnel" {
account_id = var.cloudflare_account_id
name = "zero_trust_ssh_http"
secret = random_id.argo_secret.b64_std
}
That is the entire tunnel definition. The tunnel gets a UUID that services can bind to, and because that UUID is tied to your account, the tunnel can proxy for multiple zones and hostnames within it — no per-application tunnel creation required.
Bootstrapping the Origin Server
Once the tunnel target exists, the origin-side setup is handled through Terraform’s templatefile function. The google_compute_instance resource passes five variables into a bash script stored locally as server.tpl, which is supplied to the instance’s metadata_startup_script argument.
resource "google_compute_instance" "origin" {
...
metadata_startup_script = templatefile("./server.tpl",
{
web_zone = var.cloudflare_zone,
account = var.cloudflare_account_id,
tunnel_id = cloudflare_argo_tunnel.auto_tunnel.id,
tunnel_name = cloudflare_argo_tunnel.auto_tunnel.name,
secret = random_id.argo_secret.b64_std
})
}
That startup script is where the Named Tunnel benefits become clear. Instead of the old cert.pem approach, the script writes a cert.json credentials file and a config.yml Ingress Rules file using values interpolated from the Terraform variables via heredocs. The script then installs cloudflared as a system service, so the tunnel survives reboots.
wget https://bin.equinox.io/c/VdrWdbjqyF/cloudflared-stable-linux-amd64.deb
sudo dpkg -i cloudflared-stable-linux-amd64.deb
mkdir ~/.cloudflared
touch ~/.cloudflared/cert.json
touch ~/.cloudflared/config.yml
cat > ~/.cloudflared/cert.json << "EOF"
{
"AccountTag" : "${account}",
"TunnelID" : "${tunnel_id}",
"TunnelName" : "${tunnel_name}",
"TunnelSecret" : "${secret}"
}
EOF
cat > ~/.cloudflared/config.yml << "EOF"
tunnel: ${tunnel_id}
credentials-file: /etc/cloudflared/cert.json
logfile: /var/log/cloudflared.log
loglevel: info
ingress:
- hostname: ${web_zone}
service: http://localhost:8080
- hostname: ssh.${web_zone}
service: ssh://localhost:22
- hostname: "*"
service: hello-world
EOF
sudo cloudflared service install
sudo cp -via ~/.cloudflared/cert.json /etc/cloudflared/
cd /tmp
sudo docker-compose up -d && sudo service cloudflared start
The cert.json file carries the account ID (which pins the tunnel to your account), the tunnel UUID, the tunnel name, and the 35-character secret. The config.yml file tells cloudflared which tunnel UUID to attach to, where the credentials live on disk, and where logs should go. The log level of info is appropriate for normal operations; switch to debug when troubleshooting.
Below that, the Ingress Rules define routing. The first hostname: entry proxies requests for a given hostname to localhost port 8080 for the HTTPbin service. The next maps an SSH hostname to the local SSH port. A third hostname uses a wildcard, which lets any other zone or hostname in the account point at the tunnel without adding another Ingress Rule — the service backing it is a built-in "hello world" response.
Because all inbound traffic arrives through the tunnel, you can lock down the server’s external network entirely and rely on Cloudflare as the sole ingress path. For the SSH endpoint, an Access policy provides the authentication layer.
Scoping SSH with Access
Cloudflare’s Access team publishes Terraform guidance for policy management, which makes adding a policy around the SSH endpoint straightforward. The Access configuration uses two resources: an application and a policy.
# Access policy to apply zero trust policy over SSH endpoint
resource "cloudflare_access_application" "ssh_app" {
zone_id = var.cloudflare_zone_id
name = "Access protection for ssh.${var.cloudflare_zone}"
domain = "ssh.${var.cloudflare_zone}"
session_duration = "1h"
}
resource "cloudflare_access_policy" "ssh_policy" {
application_id = cloudflare_access_application.ssh_app.id
zone_id = var.cloudflare_zone_id
name = "Example Policy for ssh.${var.cloudflare_zone}"
precedence = "1"
decision = "allow"
include {
email = [var.cloudflare_email]
}
}
The cloudflare_access_application resource picks up the zone ID and zone name from variables in the terraform.tfvars file, and restricts the domain argument to ssh.targetdomain.com. The cloudflare_access_policy resource then references that application and sets an active policy where the allowed user is the email supplied in var.cloudflare_email.
Connecting Through the Tunnel
Bringing the environment up is a standard terraform plan followed by terraform apply.

On the workstation side, cloudflared is installed and the SSH config has been updated to route the SSH hostname through it. Connecting with a local username against the remote hostname triggers the local cloudflared instance to proxy the request onward. That also launches a browser tab pointing at the Cloudflare Access application created in Terraform. The Access policy allows the email address set in var.cloudflare_email; approval within the session_duration window completes authentication and the terminal session lands on the server.

Server authentication logs reveal the connection arrives over localhost (127.0.0.1), confirming there is no external network path to the SSH port. That validates the lockdown approach: inbound networking can be fully disabled on the origin, with the tunnel as the only way in. As Cloudflare extends the Tunnel roadmap, the pattern of managing tunnels, Access policies, and origin infrastructure in a single declarative config is the direction to plan around. The complete example configuration used here is available in the terraform-zerotrust-ssh-http-gcp directory of the Cloudflare argo-tunnel-examples repository.



