
Route Cloud Run & GKE Egress Through a Static IP with Terraform | Serverless VPC Access + NAT
Cloud Run and GKE use ephemeral IPs by default, which breaks third-party IP allow-lists and audit requirements. By attaching a Serverless VPC Access connector, routing traffic through a VPC, and using Cloud NAT with reserved static IPs, you get predictable egress addresses with fully Terraform-managed infrastructure.
By Mateusz Chmielewski · Jul 27, 2026 · 17 min read
What Is Static Egress for Serverless Workloads?
Static egress means outbound traffic from Cloud Run, Cloud Functions, or GKE leaves Google Cloud through one or more predictable public IP addresses. This is achieved by routing serverless traffic into a VPC via Serverless VPC Access, then through Cloud NAT with reserved external IPs.
Think of ephemeral egress as sending mail from a random post office box every day. Static egress is like having a fixed business mailing address that recipients can recognize and trust.
| Concept | Explanation | When to use |
|---|---|---|
| Serverless VPC Access Connector | A managed connector that bridges serverless services to your VPC network. | When Cloud Run, Cloud Functions, or App Engine need to reach private RFC1918 resources or route through Cloud NAT. |
| Cloud NAT | A managed NAT gateway that lets private or serverless workloads reach the internet using external IPs. | When you need outbound internet access with static or predictable source IPs. |
| Reserved Static IP | A persistent regional external IP address that you own until released. | When third parties allow-list your outbound IP addresses or for audit and compliance. |
| Connector Subnet | A dedicated /28 subnet used exclusively by the Serverless VPC Access connector. | Always create a dedicated /28; do not share it with VMs or GKE nodes. |
| Egress Setting | Cloud Run setting that controls whether only private-ranges or all traffic flows through the connector. | Use PRIVATE_RANGES_ONLY to avoid NAT costs for public traffic; use ALL_TRAFFIC when all egress must use the static IP. |
| Cloud Router | A control-plane component that pairs with Cloud NAT to manage NAT routing. | Required for Cloud NAT; create one per region where you need static egress. |
Why Use Static Egress for Cloud Run and GKE?
By default, Cloud Run containers and GKE nodes use ephemeral public IPs or Google-managed NAT for outbound traffic. The source IP changes over time, breaking SaaS allow-lists, firewall rules, compliance audits, and API rate-limiting policies.
Serverless VPC Access plus Cloud NAT with reserved static IPs gives serverless workloads a fixed, controllable egress identity without managing proxy VMs. Combine with [GKE Spot VMs](/tutorial/spot-vms-preemptible-gke-cost-optimization) for cost optimization, [VPC Service Controls](/tutorial/terraform-vpc-service-controls-custom-module-guide) for zero-trust perimeters, and [Shared VPC](/tutorial/shared-vpc-gcp-terraform-host-service-projects-iam) for enterprise networking.
| Feature | thisService | altA | altB |
|---|---|---|---|
| Source IP Predictability | Yes (reserved IPs) | No (ephemeral NAT) | Yes (proxy VMs) |
| Operational Overhead | Low (managed) | None | High (patch, scale, monitor VMs) |
| Cloud Run Support | Native via VPC connector | N/A | Complex sidecar/proxy setup |
| GKE Support | Native via Cloud NAT | N/A | Possible but fragile |
| Cost | Medium (NAT + connector + IPs) | Low | High (VM compute) |
Prerequisites
- GCP project with billing enabled
- Terraform CLI v1.5.0+ and a GCS remote backend configured
- IAM roles: `roles/compute.networkAdmin`, `roles/compute.securityAdmin`, and `roles/editor` or custom equivalents
- APIs enabled: Compute Engine, Serverless VPC Access, Cloud Run, Kubernetes Engine
- An existing VPC or permission to create one
Step-by-Step Guide
Step 1: Configure Terraform Backend and Providers
Setting up remote state, required providers, and project/region defaults. Serverless networking resources span multiple APIs. A stable backend and pinned provider versions prevent accidental drift and team conflicts.
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
backend "gcs" {
bucket = "my-org-tfstate"
prefix = "serverless-egress/prod"
}
}
provider "google" {
project = var.project_id
region = var.region
}Step 2: Define Variables with Validation
Declaring typed variables for region, network, subnet ranges, connector sizing, and NAT IP count. Validation blocks catch misconfiguration early, such as invalid CIDR sizes or unsupported connector machine types.
variable "project_id" {
description = "GCP project ID where resources are created"
type = string
}
variable "region" {
description = "Primary region for the connector, router, and NAT"
type = string
default = "europe-west1"
}
variable "network_name" {
description = "Name of the VPC network to use or create"
type = string
default = "serverless-network"
}
variable "connector_subnet_cidr" {
description = "Dedicated /28 CIDR for the Serverless VPC Access connector"
type = string
default = "10.0.0.0/28"
validation {
condition = can(regex("^\\d+\\.\\d+\\.\\d+\\.\\d+/28
chmielewski.dev | GCP Engineering & Architecture Blueprints
Insights, guides, and architectural blueprints for creators shipping on Google Cloud Platform.
quot;, var.connector_subnet_cidr))
error_message = "Connector subnet must be a /28 CIDR block."
}
}
variable "nat_ip_count" {
description = "Number of static IPs to reserve for Cloud NAT"
type = number
default = 2
validation {
condition = var.nat_ip_count >= 1 && var.nat_ip_count <= 32
error_message = "nat_ip_count must be between 1 and 32."
}
}
variable "connector_machine_type" {
description = "Machine type for the Serverless VPC Access connector"
type = string
default = "e2-micro"
validation {
condition = contains(["f1-micro", "e2-micro", "e2-standard-4"], var.connector_machine_type)
error_message = "Unsupported connector machine type."
}
}
variable "connector_min_instances" {
type = number
default = 2
}
variable "connector_max_instances" {
type = number
default = 10
}
variable "create_gke_cluster" {
description = "Whether to create a private GKE cluster example"
type = bool
default = false
}
variable "gke_node_cidr" {
description = "CIDR for GKE node subnet"
type = string
default = "10.0.16.0/24"
}
variable "gke_pods_cidr" {
description = "Secondary CIDR for GKE pods"
type = string
default = "10.4.0.0/14"
}
variable "gke_services_cidr" {
description = "Secondary CIDR for GKE services"
type = string
default = "10.0.32.0/20"
}Step 3: Create the VPC and Connector Subnet
Building a custom-mode VPC and a dedicated /28 subnet for the Serverless VPC Access connector. The connector subnet must be exclusive to the connector. Sharing it with other resources causes IP exhaustion and routing issues.
resource "google_compute_network" "vpc" {
name = var.network_name
auto_create_subnetworks = false
routing_mode = "REGIONAL"
project = var.project_id
}
resource "google_compute_subnetwork" "connector" {
name = "${var.region}-connector-subnet"
network = google_compute_network.vpc.id
region = var.region
ip_cidr_range = var.connector_subnet_cidr
project = var.project_id
private_ip_google_access = true
log_config {
aggregation_interval = "INTERVAL_5_SEC"
flow_sampling = 0.5
metadata = "INCLUDE_ALL_METADATA"
}
labels = {
managed_by = "terraform"
purpose = "serverless-vpc-access"
}
}
resource "google_compute_subnetwork" "gke_nodes" {
count = var.create_gke_cluster ? 1 : 0
name = "${var.region}-gke-nodes"
network = google_compute_network.vpc.id
region = var.region
ip_cidr_range = var.gke_node_cidr
project = var.project_id
private_ip_google_access = true
secondary_ip_range {
range_name = "pods"
ip_cidr_range = var.gke_pods_cidr
}
secondary_ip_range {
range_name = "services"
ip_cidr_range = var.gke_services_cidr
}
}Step 4: Reserve Static IPs for Cloud NAT
Creating regional external IP addresses that Cloud NAT will use as egress sources. Without reserved IPs, Cloud NAT uses auto-allocated ephemeral addresses that change over time, defeating the purpose of allow-listing.
resource "google_compute_address" "nat" {
count = var.nat_ip_count
name = "${var.region}-nat-ip-${count.index}"
region = var.region
project = var.project_id
address_type = "EXTERNAL"
network_tier = "PREMIUM"
labels = {
managed_by = "terraform"
purpose = "cloud-nat"
}
}
output "nat_ip_addresses" {
description = "Reserved static IPs used by Cloud NAT"
value = google_compute_address.nat[*].address
}Step 5: Deploy Cloud Router and Cloud NAT
Creating a Cloud Router and Cloud NAT that uses the reserved static IPs for outbound traffic. Cloud Router is the control plane for Cloud NAT. Manual IP assignment ensures predictable egress addresses.
resource "google_compute_router" "router" {
name = "${var.region}-router"
region = var.region
network = google_compute_network.vpc.id
project = var.project_id
}
resource "google_compute_router_nat" "nat" {
name = "${var.region}-nat"
router = google_compute_router.router.name
region = var.region
project = var.project_id
nat_ip_allocate_option = "MANUAL_ONLY"
nat_ips = google_compute_address.nat[*].self_link
source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
min_ports_per_vm = 64
max_ports_per_vm = 65536
log_config {
enable = true
filter = "ERRORS_ONLY"
}
}Step 6: Create the Serverless VPC Access Connector
Deploying the managed connector that links Cloud Run to the VPC. Cloud Run cannot directly attach to a subnet; it requires a Serverless VPC Access connector to route traffic into the VPC.
resource "google_vpc_access_connector" "connector" {
name = "${var.region}-connector"
region = var.region
project = var.project_id
network = google_compute_network.vpc.id
ip_cidr_range = var.connector_subnet_cidr
machine_type = var.connector_machine_type
min_instances = var.connector_min_instances
max_instances = var.connector_max_instances
subnet {
name = google_compute_subnetwork.connector.name
project_id = var.project_id
}
depends_on = [google_compute_router_nat.nat]
}Step 7: Deploy Cloud Run Service with VPC Connector
Creating a Cloud Run service that routes egress through the VPC connector and Cloud NAT. This is the final wiring step that makes Cloud Run traffic originate from the reserved static IPs.
resource "google_service_account" "cloud_run" {
account_id = "cloud-run-static-egress"
display_name = "Cloud Run static egress service account"
project = var.project_id
}
resource "google_project_iam_member" "vpc_access_user" {
project = var.project_id
role = "roles/vpcaccess.user"
member = "serviceAccount:${google_service_account.cloud_run.email}"
}
resource "google_cloud_run_v2_service" "api" {
name = "static-egress-api"
location = var.region
project = var.project_id
template {
containers {
image = "gcr.io/cloudrun/hello:latest"
env {
name = "EGRESS_MODE"
value = "static-ip"
}
}
vpc_access {
connector = google_vpc_access_connector.connector.id
egress = "ALL_TRAFFIC"
}
service_account = google_service_account.cloud_run.email
}
depends_on = [google_project_iam_member.vpc_access_user]
}Step 8: Deploy a Private GKE Cluster Using the Same NAT
Creating a VPC-native private GKE cluster whose nodes have no external IPs and route egress through Cloud NAT. Private GKE clusters improve security by removing public IPs from nodes while still allowing outbound internet access through Cloud NAT.
resource "google_container_cluster" "primary" {
count = var.create_gke_cluster ? 1 : 0
name = "private-static-egress"
location = var.region
project = var.project_id
network = google_compute_network.vpc.id
subnetwork = google_compute_subnetwork.gke_nodes[0].id
private_cluster_config {
enable_private_nodes = true
enable_private_endpoint = false
master_ipv4_cidr_block = "172.16.0.0/28"
}
ip_allocation_policy {
cluster_secondary_range_name = "pods"
services_secondary_range_name = "services"
}
remove_default_node_pool = true
initial_node_count = 1
}
resource "google_container_node_pool" "primary_nodes" {
count = var.create_gke_cluster ? 1 : 0
name = "primary-pool"
location = var.region
cluster = google_container_cluster.primary[0].id
project = var.project_id
node_count = 2
node_config {
machine_type = "e2-medium"
oauth_scopes = ["https://www.googleapis.com/auth/cloud-platform"]
metadata = {
disable-legacy-endpoints = "true"
}
labels = {
managed_by = "terraform"
}
}
}Verification
Best Practices
- Reserve Static IPs Explicitly
- Use a Dedicated /28 Connector Subnet
- Choose the Right Egress Setting
- Size Connectors for Throughput
- Use Regional Cloud NAT per Region
- Export Static IPs as Outputs
- Monitor NAT Port Exhaustion
Common Mistakes
- {"errorCode":"CONNECTOR_SUBNET_INVALID","symptoms":"VPC connector creation fails with invalid subnet or CIDR error.","rootCause":"The connector subnet is not /28, overlaps with another range, or is not in the same region.","fixCommand":"Verify with `gcloud compute networks subnets list --project=PROJECT_ID`.","prevention":"Always allocate a clean /28 per region and validate it with a Terraform regex."}
- {"errorCode":"NAT_IP_NOT_USED","symptoms":"Outbound traffic still uses an ephemeral IP, not the reserved static IP.","rootCause":"Cloud NAT is configured with AUTO_ONLY, or the workload subnet is not included in NAT source ranges.","fixCommand":"Set `nat_ip_allocate_option = \"MANUAL_ONLY\"` and `source_subnetwork_ip_ranges_to_nat = \"ALL_SUBNETWORKS_ALL_IP_RANGES\"`.","prevention":"Use manual IP allocation and verify Cloud Run egress setting is ALL_TRAFFIC when public traffic must use the static IP."}
- {"errorCode":"ASYMMETRIC_ROUTING","symptoms":"Packets drop or connections hang when returning from an external destination.","rootCause":"Traffic leaves through Cloud NAT but returns through a different path, such as a VPN or load balancer backend.","fixCommand":"Review VPC routes and ensure return traffic reaches the same Cloud NAT gateway.","prevention":"Use Cloud NAT for all outbound internet traffic from a subnet; avoid mixing NAT and direct external IPs on the same source range."}
- {"errorCode":"CONNECTOR_SCALING_LIMIT","symptoms":"Cloud Run requests timeout or fail under load; connector CPU is high.","rootCause":"Connector `max_instances` is too low for the connection or throughput demand.","fixCommand":"Increase `max_instances` or upgrade `machine_type` to `e2-standard-4`.","prevention":"Load-test expected peak traffic and set max_instances at least 50% above observed peak."}
- {"errorCode":"GKE_IMAGE_PULL_FAILED","symptoms":"Private GKE nodes cannot start pods because images cannot be pulled.","rootCause":"Nodes have no external IP and Private Google Access is not enabled on the node subnet.","fixCommand":"Enable `private_ip_google_access = true` on the GKE node subnet.","prevention":"Enable Private Google Access and ensure Artifact Registry is reachable through restricted.googleapis.com or Private Service Connect."}
Cost Analysis
| Feature | metric | cost1k | cost10k | cost100k | cost1m |
|---|---|---|---|---|---|
| Cloud NAT Gateway | $32.40 | $32.40 | $32.40 | $32.40 | |
| Static IP Addresses | $7.20 | $7.20 | $7.20 | $7.20 | |
| Serverless VPC Access Connector | ~$15.00 | ~$30.00 | ~$75.00 | ~$150.00 | |
| NAT Data Processing | ~$22.50 | ~$45.00 | ~$112.50 | ~$225.00 |