Skip to content

Setting up Cloudflare Tunnel on Google Cloud Run

Cloud Run can hold a cloudflared container inside a VPC, egressing through Cloud NAT with no public ingress, and this guide builds that stack end to end with gcloud and with Terraform. The Cloudflare provider examples use v4 syntax; the deployment order is forced by the tunnel token, so the Cloudflare resources go first.

Prerequisites: a Google Cloud project with billing enabled, the gcloud SDK (or Terraform with the Google and Cloudflare providers), and a Cloudflare zone whose CNAME the tunnel can take.

Google CloudVPC: cloudrun (192.168.33.0/24)Internal ServicesNetwork ComponentsFirewall RulesGoogle Infrastructure(observed: 216.239.x.x)Cloudflared(10.0.10.x)(4) Returnhttpbun(10.0.10.y)(2) Route(1) Internal RequestCloud Router(5) Tunnel(3) ResponseCloud NATCloudflare Edge198.41.192.0/24198.41.200.0/24(6) EgressTCP:443 -> Google Infrastructurepriority: 999TCP/UDP:7844 -> CF Edgepriority: 1000deny-allpriority: 10000

The same thing in list form:

  1. cloudflared (internal, 10.0.10.x) reaches the Google load balancer; the LB is reached at an observed Google Infrastructure address (216.239.x.x).
  2. The load balancer routes the request to httpbun (10.0.10.y).
  3. httpbun returns the response through the load balancer.
  4. The load balancer returns it to cloudflared.
  5. cloudflared sends it to the Cloud Router.
  6. The Cloud Router hands off to Cloud NAT, which egresses to Cloudflare Edge (198.41.192.0/24, 198.41.200.0/24).

The VPC is cloudrun (192.168.33.0/24), with firewall rules TCP 443 -> Google Infrastructure (priority 999), TCP/UDP 7844 -> Cloudflare Edge (priority 1000), and deny-all (priority 10000).

Terminal window
# Create VPC network
gcloud compute networks create cloudrun \
--subnet-mode=custom \
--description="Network for Cloudflare tunnel services"
# Create subnet
gcloud compute networks subnets create cfd \
--network=cloudrun \
--region=europe-west4 \
--range=192.168.33.0/24
# Create Cloud Router
gcloud compute routers create cloudrun-router \
--network=cloudrun \
--region=europe-west4
# Create Cloud NAT
gcloud compute routers nats create cloudrun-nat \
--router=cloudrun-router \
--router-region=europe-west4 \
--region=europe-west4 \
--nat-all-subnet-ip-ranges \
--auto-allocate-nat-external-ips
# Firewall Rules
# 1. Allow Google Infrastructure Traffic (Required for internal service communication)
# IMPORTANT: Google's infrastructure IP ranges are dynamically allocated and change frequently.
# The ranges below are commonly observed, but you MUST monitor your VPC flow logs
# to identify the specific ranges used in your deployment.
# Common observed ranges include 216.239.32.0/19, but these are NOT guaranteed.
gcloud compute firewall-rules create allow-google-apis \
--network=cloudrun \
--direction=egress \
--action=allow \
--rules=tcp:443 \
--destination-ranges=216.239.32.0/19,35.190.0.0/16,130.211.0.0/22 \
--priority=999
# 2. Allow Cloudflare Edge Traffic
# Port 7844 is used by cloudflared to connect to Cloudflare
# --destination-ranges fetches current Cloudflare IPv4 ranges
gcloud compute firewall-rules create allow-cf-traffic \
--network=cloudrun \
--direction=egress \
--action=allow \
--rules=tcp:7844,udp:7844 \
--destination-ranges=$(curl -s https://www.cloudflare.com/ips-v4 | paste -sd "," -) \
--priority=1000
# 3. Deny all other egress (recommended)
gcloud compute firewall-rules create deny-all \
--network=cloudrun \
--direction=egress \
--action=deny \
--rules=all \
--destination-ranges=0.0.0.0/0 \
--priority=10000
# Google Cloud Provider configuration
provider "google" {
project = var.project_id
region = var.region
}
# VPC Network
resource "google_compute_network" "vpc_network" {
name = "cloudrun-vpc"
auto_create_subnetworks = false
description = "Network for Cloudflare tunnel services"
}
# Subnet
resource "google_compute_subnetwork" "cfd_subnet" {
name = "cfd-subnet"
ip_cidr_range = "192.168.33.0/24"
region = var.region
network = google_compute_network.vpc_network.id
private_ip_google_access = true
}
# Cloud Router
resource "google_compute_router" "router" {
name = "cloudrun-router"
region = var.region
network = google_compute_network.vpc_network.id
}
# Cloud NAT
resource "google_compute_router_nat" "nat" {
name = "cloudrun-nat"
router = google_compute_router.router.name
region = google_compute_router.router.region
nat_ip_allocate_option = "AUTO_ONLY"
source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
log_config {
enable = true
filter = "ERRORS_ONLY"
}
}
# Firewall Rules
# 1. Allow Google Infrastructure Traffic
resource "google_compute_firewall" "allow_google_apis" {
name = "allow-google-apis"
network = google_compute_network.vpc_network.id
direction = "EGRESS"
priority = 999
description = "Allow egress to Google infrastructure for internal Cloud Run communication"
allow {
protocol = "tcp"
ports = ["443"]
}
# IMPORTANT: Google infrastructure IP ranges are dynamically allocated and change frequently.
# These ranges are commonly observed but NOT guaranteed. You MUST monitor VPC flow logs
# to identify the actual ranges used in your deployment.
destination_ranges = [
"216.239.32.0/19", # Commonly observed range (NOT guaranteed)
"35.190.0.0/16", # Google APIs and services (may vary)
"130.211.0.0/22" # Additional Google services (may vary)
]
}
# 2. Allow Cloudflare Edge Traffic
resource "google_compute_firewall" "allow_cf_traffic" {
name = "allow-cf-traffic"
network = google_compute_network.vpc_network.id
direction = "EGRESS"
priority = 1000
description = "Allow egress to Cloudflare Edge for Tunnel connection"
allow {
protocol = "tcp"
ports = ["7844"] # TCP (http2) fallback for cloudflared connections
}
allow {
protocol = "udp"
ports = ["7844"] # UDP (quic), the default protocol for cloudflared connections to Cloudflare
}
# IMPORTANT: This list should be updated regularly. For production, use the
# dynamic data source approach shown in the "Automating IP Updates" section.
# To get latest IPs: curl https://api.cloudflare.com/client/v4/ips | jq .
# For China network: curl 'https://api.cloudflare.com/client/v4/ips?networks=jdcloud' | jq .
destination_ranges = [
"173.245.48.0/20",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"141.101.64.0/18",
"108.162.192.0/18",
"190.93.240.0/20",
"188.114.96.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"162.158.0.0/15",
"104.16.0.0/13",
"104.24.0.0/14",
"172.64.0.0/13",
"131.0.72.0/22"
]
}
# 3. Deny all other egress
resource "google_compute_firewall" "deny_all_egress" {
name = "deny-all"
network = google_compute_network.vpc_network.id
direction = "EGRESS"
priority = 10000
description = "Deny all other egress traffic"
deny {
protocol = "all"
}
destination_ranges = ["0.0.0.0/0"]
}
Terminal window
gcloud run deploy httpbun \
--image=kennethreitz/httpbin \
--platform=managed \
--region=europe-west4 \
--port=80 \
--network=cloudrun \
--subnet=cfd \
--ingress=internal
Terminal window
gcloud run deploy cloudflared \
--image=docker.io/cloudflare/cloudflared:latest \
--platform=managed \
--region=europe-west4 \
--command=cloudflared \
--args="tunnel,--no-autoupdate,--metrics,0.0.0.0:10000,--metrics-update-freq,5s,run,--token,your-tunnel-token" \
--network=cloudrun \
--subnet=cfd \
--port=10000 \
--ingress=internal \
--min-instances=1
# Internal service (httpbun)
resource "google_cloud_run_v2_service" "httpbun_service" {
name = "httpbun"
location = var.region
template {
containers {
image = "kennethreitz/httpbin"
ports {
container_port = 80
}
}
vpc_access {
network_interfaces {
network = google_compute_network.vpc_network.id
subnetwork = google_compute_subnetwork.cfd_subnet.id
}
egress = "ALL_TRAFFIC"
}
}
ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY"
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
percent = 100
}
}
# Cloudflared service
resource "google_cloud_run_v2_service" "cloudflared_service" {
name = "cloudflared"
location = var.region
template {
scaling {
min_instance_count = 1 # Maintains tunnel connectivity
}
containers {
image = "cloudflare/cloudflared:latest"
args = [
"tunnel",
"--no-autoupdate",
"--metrics",
"0.0.0.0:10000",
"--metrics-update-freq",
"5s",
"run",
"--token",
var.tunnel_token # Provided via a variable or secret manager
]
ports {
container_port = 10000
}
}
vpc_access {
network_interfaces {
network = google_compute_network.vpc_network.id
subnetwork = google_compute_subnetwork.cfd_subnet.id
}
egress = "ALL_TRAFFIC"
}
}
ingress = "INGRESS_TRAFFIC_INTERNAL_ONLY"
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
percent = 100
}
}
  1. Log in to your Cloudflare account
  2. Navigate to Zero Trust > Access > Tunnels
  3. Click “Create Tunnel”
  4. Follow the setup wizard to create a tunnel
  5. Note the tunnel token which will be used in the cloudflared service
# Configure Cloudflare provider
# Note: Using v4 syntax which is stable. v5 has breaking changes and known issues.
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
# Create Cloudflare Tunnel
resource "cloudflare_tunnel" "gcp_cloudrun_tunnel" {
account_id = var.cloudflare_account_id
name = "gcp-cloudrun-tunnel"
# secret attribute is optional - Cloudflare will generate one if not provided
}
# Configure the tunnel
resource "cloudflare_tunnel_config" "gcp_cloudrun_config" {
account_id = var.cloudflare_account_id
tunnel_id = cloudflare_tunnel.gcp_cloudrun_tunnel.id
config {
ingress_rule {
hostname = var.public_hostname # e.g., "app.example.com"
# Cloud Run URI includes https:// already, so we need to extract just the hostname
service = "http://${trimprefix(google_cloud_run_v2_service.httpbun_service.uri, "https://")}"
}
# Default catch-all rule
ingress_rule {
service = "http_status:404"
}
}
}
# Create DNS record for the tunnel
resource "cloudflare_record" "tunnel_dns" {
zone_id = var.cloudflare_zone_id
name = var.subdomain # e.g., "app" for app.example.com
value = "${cloudflare_tunnel.gcp_cloudrun_tunnel.id}.cfargotunnel.com"
type = "CNAME"
proxied = true
}
# Output the tunnel token (sensitive - handle with care)
output "tunnel_token" {
value = cloudflare_tunnel.gcp_cloudrun_tunnel.tunnel_token
sensitive = true
description = "Token for authenticating cloudflared service to Cloudflare"
}
  1. Create Cloudflare Tunnel:

    • Deploy the Cloudflare resources first to obtain a tunnel token
    • Store the token securely (e.g., in Google Secret Manager)
  2. Deploy Google Cloud Resources:

    • Set up VPC, subnets, router, NAT and firewall rules
    • Deploy internal service (httpbun)
    • Deploy cloudflared service with the tunnel token
  3. Configure Secret Management (recommended for production):

    • Store the tunnel token in Google Secret Manager
    • Reference the secret in your Cloud Run service
# Example: Store token in Secret Manager
resource "google_secret_manager_secret" "tunnel_token" {
secret_id = "cf-tunnel-token"
replication {
automatic = true
}
}
resource "google_secret_manager_secret_version" "tunnel_token_version" {
secret = google_secret_manager_secret.tunnel_token.id
secret_data = cloudflare_tunnel.gcp_cloudrun_tunnel.tunnel_token
}
  1. Cloud Run services are automatically assigned private IPs in the VPC subnet range (10.0.10.x)
  2. Even with internal ingress, service-to-service communication routes through Google’s infrastructure:
    • Internal requests are processed by Google’s routing layer
    • Traffic may appear to originate from various Google-owned IP ranges (commonly 216.239.x.x)
    • The exact IP ranges can vary based on region, service configuration, and Google’s internal routing
  3. The private IPs are used for VPC integration but HTTP(S) requests between services use Google’s internal routing
  4. All egress to external destinations (like Cloudflare) occurs through Cloud NAT
  1. Internal ingress ensures services are not accessible from the public internet
  2. Firewall rules restrict egress to only necessary destinations:
    • Google infrastructure ranges (required for internal service communication and Google APIs)
    • Cloudflare edge servers (required for tunnel)
  3. Deny-all rule blocks any other egress traffic
  4. Services maintain private IP addresses but cannot be accessed directly via these IPs
  5. Important: Always use the complete list of Cloudflare IP ranges from https://www.cloudflare.com/ips/ for your firewall rules
  1. Always use min_instance_count=1 for cloudflared to maintain tunnel connectivity
  2. Configure proper firewall rules to restrict egress
  3. Regularly update your Cloudflare IP allowlists using:
Terminal window
# Standard Cloudflare IPs
curl https://api.cloudflare.com/client/v4/ips | jq .
# Including China network (if needed)
curl 'https://api.cloudflare.com/client/v4/ips?networks=jdcloud' | jq .
  1. Enable Cloud NAT logging for troubleshooting
  2. Monitor VPC flow logs for unexpected traffic
  3. Use Terraform for infrastructure as code to ensure consistency
  4. Store sensitive information like tunnel tokens in a secret manager
  5. Use separate Terraform modules or states for Cloudflare and GCP resources if needed

For production environments, consider automating the Cloudflare IP updates:

# Example: Dynamic Cloudflare IP ranges in Terraform
data "http" "cloudflare_ips" {
url = "https://api.cloudflare.com/client/v4/ips"
request_headers = {
Accept = "application/json"
}
}
locals {
cloudflare_ip_data = jsondecode(data.http.cloudflare_ips.response_body)
cloudflare_ipv4_cidrs = local.cloudflare_ip_data.result.ipv4_cidrs
}
# Then use local.cloudflare_ipv4_cidrs for your firewall rule destination_ranges
  1. Service-to-service communication failing:

    • Check VPC flow logs to identify which Google IP ranges are being used
    • Verify firewall rules allow traffic to observed Google infrastructure ranges
    • Check service ingress settings (should be set to internal)
    • Verify both services are in the same region
    • Consider temporarily allowing broader Google IP ranges for debugging
  2. Tunnel connectivity issues:

    • Verify firewall rules allow all Cloudflare IPs
    • Check NAT configuration
    • Review cloudflared logs
    • Ensure tunnel token is correct
  3. Configuration challenges:

    • For multi-environment setups, use Terraform workspaces or separate state files
    • Handle chicken-and-egg dependencies by using outputs between separate applies
Terminal window
# Check service configuration
gcloud run services describe SERVICE_NAME --region=europe-west4
# View logs
gcloud run services logs read SERVICE_NAME --region=europe-west4
# Check NAT status
gcloud compute routers nats describe cloudrun-nat \
--router=cloudrun-router \
--router-region=europe-west4
  • gcloud run services describe cloudflared --region=europe-west4 shows the container up with the tunnel arguments and --min-instances=1; a scaled-to-zero revision is the first thing to rule out when the tunnel drops.
  • gcloud run services logs read cloudflared --region=europe-west4 shows the tunnel registering; the Cloudflare dashboard (Zero Trust > Access > Tunnels) shows the same tunnel healthy from the other side.
  • gcloud compute routers nats describe cloudrun-nat --router=cloudrun-router --router-region=europe-west4 confirms the NAT router’s settings, and its logs are where an egress that never left the VPC shows up.
  • The published hostname resolves to the tunnel CNAME and serves the internal service, which is the only check that covers the ingress rule, the firewall rules and the token together.
  • Google’s infrastructure ranges are dynamically allocated. The ranges in the firewall rules (216.239.32.0/19, 35.190.0.0/16, 130.211.0.0/22) are what was observed, not a guarantee, so read VPC flow logs to find the ranges your deployment actually uses.
  • The Cloudflare IP list has to be refreshed from https://api.cloudflare.com/client/v4/ips rather than copied by hand; the Automating IP updates section does it with the http data source.
  • Port 7844 carries the QUIC (UDP) default and the http2 (TCP) fallback, so the egress rule needs both protocols.
  • min_instance_count = 1 on cloudflared: a scaled-to-zero revision drops the tunnel.
  • Internal ingress does not keep service-to-service traffic inside the subnet. Cloud Run routes it through Google’s infrastructure, which is why the egress rule to Google ranges is required even though both services are on the same VPC.
  • The tunnel token is a chicken-and-egg dependency: apply the Cloudflare resources first, then the Cloud Run services, and keep the token in Secret Manager rather than in the service definition.
  • google_cloud_run_v2_service.httpbun_service.uri carries the https:// scheme, so the tunnel ingress service needs trimprefix(..., "https://").
  • The Cloudflare provider examples pin v4 syntax deliberately; v5 has breaking changes and known issues.

No file paths are pinned by this guide; the infrastructure is gcloud commands and Terraform resources.

ObjectWhat it is
cloudrun VPC and cfd subnet (192.168.33.0/24)The network the Cloud Run services are attached to
cloudrun-router and cloudrun-natCloud Router and NAT, the path all egress takes to Cloudflare Edge
allow-google-apis, allow-cf-traffic, deny-allThe three egress firewall rules, at priorities 999, 1000 and 10000
httpbun and cloudflared Cloud Run servicesThe internal service and the tunnel container, both INGRESS_TRAFFIC_INTERNAL_ONLY
cloudflare_tunnel, cloudflare_tunnel_config, cloudflare_recordThe tunnel, its ingress rules and the cfargotunnel.com CNAME
cf-tunnel-tokenThe Secret Manager secret holding the tunnel token
data.http.cloudflare_ips and local.cloudflare_ipv4_cidrsThe dynamic Cloudflare range list for the firewall rule
  • Traefik on k3s - the same arrangement on a Kubernetes cluster: cloudflared in the cluster as ingress, with OpenTofu managing the tunnel config and DNS records.
  • Multi-site Vaultwarden - Cloudflare Tunnel CNAMEs used as load balancer origins, for the case where neither site has a stable public IP.