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.
Architecture overview
Section titled “Architecture overview”The same thing in list form:
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).- The load balancer routes the request to
httpbun(10.0.10.y). httpbunreturns the response through the load balancer.- The load balancer returns it to
cloudflared. cloudflaredsends it to the Cloud Router.- 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).
Network setup
Section titled “Network setup”Using gcloud (command line)
Section titled “Using gcloud (command line)”# Create VPC networkgcloud compute networks create cloudrun \ --subnet-mode=custom \ --description="Network for Cloudflare tunnel services"
# Create subnetgcloud compute networks subnets create cfd \ --network=cloudrun \ --region=europe-west4 \ --range=192.168.33.0/24
# Create Cloud Routergcloud compute routers create cloudrun-router \ --network=cloudrun \ --region=europe-west4
# Create Cloud NATgcloud 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 rangesgcloud 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=10000Using Terraform
Section titled “Using Terraform”# Google Cloud Provider configurationprovider "google" { project = var.project_id region = var.region}
# VPC Networkresource "google_compute_network" "vpc_network" { name = "cloudrun-vpc" auto_create_subnetworks = false description = "Network for Cloudflare tunnel services"}
# Subnetresource "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 Routerresource "google_compute_router" "router" { name = "cloudrun-router" region = var.region network = google_compute_network.vpc_network.id}
# Cloud NATresource "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 Trafficresource "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 Trafficresource "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 egressresource "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"]}Service deployment
Section titled “Service deployment”Using gcloud (command line)
Section titled “Using gcloud (command line)”Deploy internal service (httpbun)
Section titled “Deploy internal service (httpbun)”gcloud run deploy httpbun \ --image=kennethreitz/httpbin \ --platform=managed \ --region=europe-west4 \ --port=80 \ --network=cloudrun \ --subnet=cfd \ --ingress=internalDeploy Cloudflare Tunnel
Section titled “Deploy Cloudflare Tunnel”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=1Using Terraform
Section titled “Using Terraform”# 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 serviceresource "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 }}Cloudflare Tunnel configuration
Section titled “Cloudflare Tunnel configuration”Using Cloudflare dashboard
Section titled “Using Cloudflare dashboard”- Log in to your Cloudflare account
- Navigate to Zero Trust > Access > Tunnels
- Click “Create Tunnel”
- Follow the setup wizard to create a tunnel
- Note the tunnel token which will be used in the cloudflared service
Using Terraform
Section titled “Using Terraform”# 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 Tunnelresource "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 tunnelresource "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 tunnelresource "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"}Deployment workflow
Section titled “Deployment workflow”-
Create Cloudflare Tunnel:
- Deploy the Cloudflare resources first to obtain a tunnel token
- Store the token securely (e.g., in Google Secret Manager)
-
Deploy Google Cloud Resources:
- Set up VPC, subnets, router, NAT and firewall rules
- Deploy internal service (httpbun)
- Deploy cloudflared service with the tunnel token
-
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 Managerresource "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}Important notes
Section titled “Important notes”Network flow
Section titled “Network flow”- Cloud Run services are automatically assigned private IPs in the VPC subnet range (10.0.10.x)
- 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
- The private IPs are used for VPC integration but HTTP(S) requests between services use Google’s internal routing
- All egress to external destinations (like Cloudflare) occurs through Cloud NAT
Security considerations
Section titled “Security considerations”- Internal ingress ensures services are not accessible from the public internet
- 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)
- Deny-all rule blocks any other egress traffic
- Services maintain private IP addresses but cannot be accessed directly via these IPs
- Important: Always use the complete list of Cloudflare IP ranges from https://www.cloudflare.com/ips/ for your firewall rules
Best practices
Section titled “Best practices”- Always use
min_instance_count=1for cloudflared to maintain tunnel connectivity - Configure proper firewall rules to restrict egress
- Regularly update your Cloudflare IP allowlists using:
# Standard Cloudflare IPscurl https://api.cloudflare.com/client/v4/ips | jq .
# Including China network (if needed)curl 'https://api.cloudflare.com/client/v4/ips?networks=jdcloud' | jq .- Enable Cloud NAT logging for troubleshooting
- Monitor VPC flow logs for unexpected traffic
- Use Terraform for infrastructure as code to ensure consistency
- Store sensitive information like tunnel tokens in a secret manager
- Use separate Terraform modules or states for Cloudflare and GCP resources if needed
Automating IP updates
Section titled “Automating IP updates”For production environments, consider automating the Cloudflare IP updates:
# Example: Dynamic Cloudflare IP ranges in Terraformdata "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_rangesTroubleshooting
Section titled “Troubleshooting”Common issues
Section titled “Common issues”-
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
-
Tunnel connectivity issues:
- Verify firewall rules allow all Cloudflare IPs
- Check NAT configuration
- Review cloudflared logs
- Ensure tunnel token is correct
-
Configuration challenges:
- For multi-environment setups, use Terraform workspaces or separate state files
- Handle chicken-and-egg dependencies by using outputs between separate applies
Useful commands
Section titled “Useful commands”# Check service configurationgcloud run services describe SERVICE_NAME --region=europe-west4
# View logsgcloud run services logs read SERVICE_NAME --region=europe-west4
# Check NAT statusgcloud compute routers nats describe cloudrun-nat \ --router=cloudrun-router \ --router-region=europe-west4Verification
Section titled “Verification”gcloud run services describe cloudflared --region=europe-west4shows 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-west4shows 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-west4confirms 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.
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”- 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/ipsrather than copied by hand; the Automating IP updates section does it with thehttpdata source. - Port 7844 carries the QUIC (UDP) default and the http2 (TCP) fallback, so the egress rule needs both protocols.
min_instance_count = 1oncloudflared: 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.uricarries thehttps://scheme, so the tunnel ingress service needstrimprefix(..., "https://").- The Cloudflare provider examples pin v4 syntax deliberately; v5 has breaking changes and known issues.
File reference
Section titled “File reference”No file paths are pinned by this guide; the infrastructure is gcloud commands and Terraform resources.
| Object | What 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-nat | Cloud Router and NAT, the path all egress takes to Cloudflare Edge |
allow-google-apis, allow-cf-traffic, deny-all | The three egress firewall rules, at priorities 999, 1000 and 10000 |
httpbun and cloudflared Cloud Run services | The internal service and the tunnel container, both INGRESS_TRAFFIC_INTERNAL_ONLY |
cloudflare_tunnel, cloudflare_tunnel_config, cloudflare_record | The tunnel, its ingress rules and the cfargotunnel.com CNAME |
cf-tunnel-token | The Secret Manager secret holding the tunnel token |
data.http.cloudflare_ips and local.cloudflare_ipv4_cidrs | The dynamic Cloudflare range list for the firewall rule |
Related docs
Section titled “Related docs”- Traefik on k3s - the same arrangement on a Kubernetes cluster:
cloudflaredin 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.