Terraform GCP Hub-Spoke Setup for Private GKE

Below is a production-style Terraform baseline for GCP Hub-Spoke + Private GKE. It uses current Terraform/GCP patterns: private GKE, Workload Identity, Cloud NAT, Shared VPC-ready layout, and secure node pool defaults. Google’s docs confirm Workload Identity Federation is enabled with PROJECT_ID.svc.id.goog, Cloud NAT is managed NAT for private outbound access, and Terraform is officially supported for GKE provisioning. (Google Cloud Documentation)


Repo layout

gcp-gke-hub-spoke/
├── providers.tf
├── variables.tf
├── main.tf
├── outputs.tf
├── terraform.tfvars
└── modules/
├── network/
│ └── main.tf
├── nat/
│ └── main.tf
└── gke-private/
└── main.tf

providers.tf

terraform {
required_version = ">= 1.6.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}
provider "google" {
project = var.hub_project_id
region = var.region
}

variables.tf

variable "hub_project_id" {}
variable "spoke_project_id" {}
variable "region" {
default = "northamerica-northeast1"
}
variable "hub_vpc_name" {
default = "hub-vpc"
}
variable "spoke_vpc_name" {
default = "spoke-prod-vpc"
}
variable "gke_cluster_name" {
default = "prod-private-gke"
}
variable "gke_subnet_cidr" {
default = "10.10.0.0/20"
}
variable "pod_cidr" {
default = "10.20.0.0/16"
}
variable "service_cidr" {
default = "10.30.0.0/20"
}
variable "master_ipv4_cidr_block" {
default = "172.16.0.0/28"
}

main.tf

module "hub_network" {
source = "./modules/network"
project_id = var.hub_project_id
name = var.hub_vpc_name
region = var.region
subnets = {
hub-transit = "10.0.0.0/24"
hub-security = "10.0.1.0/24"
}
}
module "spoke_network" {
source = "./modules/network"
project_id = var.spoke_project_id
name = var.spoke_vpc_name
region = var.region
subnets = {
gke-nodes = var.gke_subnet_cidr
}
secondary_ranges = {
gke-nodes = {
pods = var.pod_cidr
services = var.service_cidr
}
}
}
module "spoke_nat" {
source = "./modules/nat"
project_id = var.spoke_project_id
region = var.region
network = module.spoke_network.network_self_link
router_name = "spoke-prod-router"
nat_name = "spoke-prod-cloud-nat"
}
module "private_gke" {
source = "./modules/gke-private"
project_id = var.spoke_project_id
region = var.region
cluster_name = var.gke_cluster_name
network = module.spoke_network.network_self_link
subnetwork = module.spoke_network.subnet_self_links["gke-nodes"]
pod_range_name = "pods"
service_range_name = "services"
master_ipv4_cidr_block = var.master_ipv4_cidr_block
}

Module: modules/network/main.tf

variable "project_id" {}
variable "name" {}
variable "region" {}
variable "subnets" {
type = map(string)
}
variable "secondary_ranges" {
type = map(any)
default = {}
}
resource "google_compute_network" "vpc" {
project = var.project_id
name = var.name
auto_create_subnetworks = false
routing_mode = "GLOBAL"
}
resource "google_compute_subnetwork" "subnet" {
for_each = var.subnets
project = var.project_id
name = each.key
region = var.region
network = google_compute_network.vpc.id
ip_cidr_range = each.value
private_ip_google_access = true
dynamic "secondary_ip_range" {
for_each = lookup(var.secondary_ranges, each.key, {})
content {
range_name = secondary_ip_range.key
ip_cidr_range = secondary_ip_range.value
}
}
log_config {
aggregation_interval = "INTERVAL_5_SEC"
flow_sampling = 0.5
metadata = "INCLUDE_ALL_METADATA"
}
}
resource "google_compute_firewall" "deny_all_ingress" {
project = var.project_id
name = "${var.name}-deny-all-ingress"
network = google_compute_network.vpc.name
direction = "INGRESS"
priority = 65534
deny {
protocol = "all"
}
source_ranges = ["0.0.0.0/0"]
}
resource "google_compute_firewall" "allow_internal" {
project = var.project_id
name = "${var.name}-allow-internal"
network = google_compute_network.vpc.name
direction = "INGRESS"
priority = 1000
allow {
protocol = "tcp"
}
allow {
protocol = "udp"
}
allow {
protocol = "icmp"
}
source_ranges = values(var.subnets)
}
output "network_self_link" {
value = google_compute_network.vpc.self_link
}
output "subnet_self_links" {
value = {
for k, v in google_compute_subnetwork.subnet : k => v.self_link
}
}

Module: modules/nat/main.tf

variable "project_id" {}
variable "region" {}
variable "network" {}
variable "router_name" {}
variable "nat_name" {}
resource "google_compute_router" "router" {
project = var.project_id
name = var.router_name
region = var.region
network = var.network
}
resource "google_compute_router_nat" "nat" {
project = var.project_id
name = var.nat_name
router = google_compute_router.router.name
region = var.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"
}
}

Module: modules/gke-private/main.tf

variable "project_id" {}
variable "region" {}
variable "cluster_name" {}
variable "network" {}
variable "subnetwork" {}
variable "pod_range_name" {}
variable "service_range_name" {}
variable "master_ipv4_cidr_block" {}
resource "google_service_account" "gke_nodes" {
project = var.project_id
account_id = "${var.cluster_name}-nodes"
display_name = "GKE node service account"
}
resource "google_project_iam_member" "gke_node_logging" {
project = var.project_id
role = "roles/logging.logWriter"
member = "serviceAccount:${google_service_account.gke_nodes.email}"
}
resource "google_project_iam_member" "gke_node_monitoring" {
project = var.project_id
role = "roles/monitoring.metricWriter"
member = "serviceAccount:${google_service_account.gke_nodes.email}"
}
resource "google_container_cluster" "cluster" {
project = var.project_id
name = var.cluster_name
location = var.region
network = var.network
subnetwork = var.subnetwork
remove_default_node_pool = true
initial_node_count = 1
release_channel {
channel = "REGULAR"
}
workload_identity_config {
workload_pool = "${var.project_id}.svc.id.goog"
}
private_cluster_config {
enable_private_nodes = true
enable_private_endpoint = true
master_ipv4_cidr_block = var.master_ipv4_cidr_block
}
ip_allocation_policy {
cluster_secondary_range_name = var.pod_range_name
services_secondary_range_name = var.service_range_name
}
master_authorized_networks_config {}
network_policy {
enabled = true
provider = "CALICO"
}
addons_config {
network_policy_config {
disabled = false
}
http_load_balancing {
disabled = false
}
}
logging_config {
enable_components = [
"SYSTEM_COMPONENTS",
"WORKLOADS",
"APISERVER",
"CONTROLLER_MANAGER",
"SCHEDULER"
]
}
monitoring_config {
enable_components = [
"SYSTEM_COMPONENTS",
"APISERVER",
"CONTROLLER_MANAGER",
"SCHEDULER"
]
}
enable_shielded_nodes = true
binary_authorization {
evaluation_mode = "PROJECT_SINGLETON_POLICY_ENFORCE"
}
}
resource "google_container_node_pool" "secure_pool" {
project = var.project_id
name = "secure-pool"
location = var.region
cluster = google_container_cluster.cluster.name
node_count = 2
node_config {
machine_type = "e2-standard-4"
service_account = google_service_account.gke_nodes.email
oauth_scopes = ["https://www.googleapis.com/auth/cloud-platform"]
shielded_instance_config {
enable_secure_boot = true
enable_integrity_monitoring = true
}
workload_metadata_config {
mode = "GKE_METADATA"
}
labels = {
environment = "prod"
security = "restricted"
}
metadata = {
disable-legacy-endpoints = "true"
}
}
management {
auto_repair = true
auto_upgrade = true
}
autoscaling {
min_node_count = 2
max_node_count = 6
}
}
output "cluster_name" {
value = google_container_cluster.cluster.name
}
output "endpoint" {
value = google_container_cluster.cluster.endpoint
sensitive = true
}

terraform.tfvars

hub_project_id = "my-hub-project"
spoke_project_id = "my-spoke-prod-project"
region = "northamerica-northeast1"
hub_vpc_name = "hub-vpc"
spoke_vpc_name = "spoke-prod-vpc"
gke_cluster_name = "prod-private-gke"

Deploy

terraform init
terraform fmt -recursive
terraform validate
terraform plan
terraform apply

Important production notes

This baseline creates separate hub and spoke VPCs, but true centralized egress through the hub needs one of these designs:

Option 1: Shared VPC
Hub/Host Project owns the VPC
Spoke/Service Projects consume subnets
Option 2: Network Connectivity Center
Hub connects spokes with routing control
Option 3: VPC Peering
Simple hub-spoke, but no transitive routing

For real enterprise GKE, I would use:

Shared VPC + Private GKE + Cloud NAT + Cloud Armor + Artifact Registry
+ Binary Authorization + Secret Manager + Workload Identity
+ NetworkPolicy + centralized logging

Use this as the secure base, then add Cloud Armor ingress, Private Service Connect, and Secret Manager CSI driver next.

Leave a Reply