Quick take — A reusable Terraform module for google_network_connectivity_hub and google_network_connectivity_spoke on hashicorp/google ~> 5.0: a transit hub with VPC, VPN-tunnel, interconnect, and router-appliance spokes plus optional auto-accept and export-psc. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "google" {
project = "my-project"
region = "us-central1"
}
module "network_connectivity_hub" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-network-connectivity-hub?ref=v1.0.0"
project_id = "..." # GCP project ID that owns the hub and its spokes.
name = "..." # Hub name (1-63 chars, lowercase RFC1035), unique in the…
spokes[*].name = "..." # Spoke resource name.
spokes[*].location = "..." # "global" for VPC spokes; the region for hybrid spokes.
spokes[*].type = "..." # One of vpc, vpn_tunnels, interconnect, router_appliance.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Network Connectivity Hub is the control-plane object at the centre of GCP’s Network Connectivity Center (NCC). A google_network_connectivity_hub is a global, project-scoped resource that does not carry traffic itself — instead it is the meeting point that spokes attach to, and GCP programs the resulting any-to-any routes between every accepted spoke. Spokes come in several flavours: a VPC spoke wires an entire VPC network into the mesh so it exchanges routes with all other spokes; hybrid spokes attach linked_vpn_tunnels, linked_interconnect_attachments, or linked_router_appliance_instances so on-prem and other-cloud sites join the same fabric; and producer VPC spokes plug managed-service networks in. The hub gives you a full or partial mesh between sites and VPCs without building an N-squared web of VPC peerings or a self-managed transit VPC.
Wrapping google_network_connectivity_hub plus google_network_connectivity_spoke in a module pays off because the raw resources hide real footguns. A hub is global but each hybrid spoke is regional and must name the region its underlying VPN tunnels or VLAN attachments live in; the exactly-one-of constraint across the four linked_* blocks is easy to violate; VPC spokes added from another project trigger the spoke-acceptance handshake (spoke_type / RejectionDetails) that bites teams who forget the hub owner must approve cross-project spokes; and linked_router_appliance_instances requires a site_to_site_data_transfer flag whose meaning differs between intra- and inter-region paths. This module turns all of that into validated, var-driven inputs, lets you stamp out many spokes from one map, optionally configures the hub’s auto-accept project allow-list and PSC export, and exports the hub id, the route tables, and a per-spoke map of IDs and state/unique_id that downstream BGP, firewall, and monitoring code needs.
When to use it
- You are replacing a full mesh of VPC Network Peerings (which is non-transitive and caps out) with a transitive hub-and-spoke mesh so every VPC can reach every other VPC through one fabric.
- You run hybrid connectivity at scale — many HA VPN tunnels or Interconnect VLAN attachments across regions — and want them all to share routes through one NCC hub instead of per-VPC Cloud Routers that cannot exchange routes with each other.
- You are deploying a third-party / NVA-based SD-WAN or firewall as a Router Appliance and need it to participate in GCP routing via
linked_router_appliance_instances. - You operate a multi-project landing zone where spoke VPCs live in different projects and you want the hub owner to gate which spokes join via spoke acceptance and an auto-accept allow-list.
- Skip it when two VPCs simply need to talk and non-transitive VPC Peering or a single HA VPN suffices — NCC adds a hub object and per-spoke charges you would not use.
Module structure
terraform-module-gcp-network-connectivity-hub/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# Auto-accept is only emitted when at least one project is allow-listed.
enable_auto_accept = length(var.auto_accept_project_ids) > 0
}
resource "google_network_connectivity_hub" "this" {
name = var.name
project = var.project_id
description = var.description
labels = var.labels
# Export Private Service Connect routes learned by the hub to its spokes.
export_psc = var.export_psc
# Optionally let spokes from named projects attach without manual approval.
dynamic "policy_mode" {
for_each = var.policy_mode != null ? [var.policy_mode] : []
content {
# PRESET keeps the default any-to-any behaviour; CUSTOM unlocks route tables.
}
}
dynamic "auto_accept" {
for_each = local.enable_auto_accept ? [1] : []
content {
auto_accept_projects = var.auto_accept_project_ids
}
}
}
resource "google_network_connectivity_spoke" "this" {
for_each = var.spokes
name = each.value.name
project = var.project_id
location = each.value.location
hub = google_network_connectivity_hub.this.id
description = try(each.value.description, null)
labels = try(each.value.labels, null)
# --- VPC spoke (global location only) ---------------------------------
dynamic "linked_vpc_network" {
for_each = each.value.type == "vpc" ? [each.value.linked_vpc_network] : []
content {
uri = linked_vpc_network.value.uri
exclude_export_ranges = try(linked_vpc_network.value.exclude_export_ranges, null)
include_export_ranges = try(linked_vpc_network.value.include_export_ranges, null)
}
}
# --- HA VPN tunnel spoke (regional) -----------------------------------
dynamic "linked_vpn_tunnels" {
for_each = each.value.type == "vpn_tunnels" ? [each.value.linked_vpn_tunnels] : []
content {
uris = linked_vpn_tunnels.value.uris
site_to_site_data_transfer = linked_vpn_tunnels.value.site_to_site_data_transfer
include_import_ranges = try(linked_vpn_tunnels.value.include_import_ranges, null)
}
}
# --- Interconnect VLAN attachment spoke (regional) --------------------
dynamic "linked_interconnect_attachments" {
for_each = each.value.type == "interconnect" ? [each.value.linked_interconnect_attachments] : []
content {
uris = linked_interconnect_attachments.value.uris
site_to_site_data_transfer = linked_interconnect_attachments.value.site_to_site_data_transfer
include_import_ranges = try(linked_interconnect_attachments.value.include_import_ranges, null)
}
}
# --- Router Appliance (NVA) spoke (regional) --------------------------
dynamic "linked_router_appliance_instances" {
for_each = each.value.type == "router_appliance" ? [each.value.linked_router_appliance_instances] : []
content {
site_to_site_data_transfer = linked_router_appliance_instances.value.site_to_site_data_transfer
dynamic "instances" {
for_each = linked_router_appliance_instances.value.instances
content {
virtual_machine = instances.value.virtual_machine
ip_address = instances.value.ip_address
}
}
}
}
}
# variables.tf
variable "project_id" {
type = string
description = "GCP project ID that owns the Network Connectivity hub and its spokes."
}
variable "name" {
type = string
description = "Name of the Network Connectivity hub. Globally unique within the project."
validation {
condition = can(regex("^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$", var.name))
error_message = "name must be 1-63 chars, lowercase letters, digits or hyphens, starting with a letter."
}
}
variable "description" {
type = string
description = "Optional human-readable description for the hub."
default = null
}
variable "labels" {
type = map(string)
description = "Labels to apply to the hub for cost allocation and ownership."
default = {}
}
variable "export_psc" {
type = bool
description = "Whether the hub exports Private Service Connect propagated connections to its spokes."
default = false
}
variable "policy_mode" {
type = string
description = "Routing policy mode of the hub: PRESET (default any-to-any mesh) or CUSTOM (route-table driven). Leave null for the provider default (PRESET)."
default = null
validation {
condition = var.policy_mode == null || contains(["PRESET", "CUSTOM"], var.policy_mode)
error_message = "policy_mode must be PRESET, CUSTOM, or null."
}
}
variable "auto_accept_project_ids" {
type = list(string)
description = "Project IDs whose spokes are auto-accepted into the hub without manual approval. Empty = manual acceptance for all cross-project spokes."
default = []
}
variable "spokes" {
description = <<-EOT
Map of spokes to attach to the hub, keyed by a stable logical name.
Exactly one linked_* block is set per spoke based on `type`:
- "vpc" -> linked_vpc_network (location MUST be "global")
- "vpn_tunnels" -> linked_vpn_tunnels (location = region of the tunnels)
- "interconnect" -> linked_interconnect_attachments (location = region)
- "router_appliance" -> linked_router_appliance_instances (location = region)
EOT
type = map(object({
name = string
location = string
type = string
description = optional(string)
labels = optional(map(string))
linked_vpc_network = optional(object({
uri = string
exclude_export_ranges = optional(list(string))
include_export_ranges = optional(list(string))
}))
linked_vpn_tunnels = optional(object({
uris = list(string)
site_to_site_data_transfer = bool
include_import_ranges = optional(list(string))
}))
linked_interconnect_attachments = optional(object({
uris = list(string)
site_to_site_data_transfer = bool
include_import_ranges = optional(list(string))
}))
linked_router_appliance_instances = optional(object({
site_to_site_data_transfer = bool
instances = list(object({
virtual_machine = string
ip_address = string
}))
}))
}))
default = {}
validation {
condition = alltrue([
for s in values(var.spokes) :
contains(["vpc", "vpn_tunnels", "interconnect", "router_appliance"], s.type)
])
error_message = "Each spoke.type must be one of: vpc, vpn_tunnels, interconnect, router_appliance."
}
validation {
# VPC spokes are global; every other spoke flavour is regional.
condition = alltrue([
for s in values(var.spokes) :
(s.type == "vpc") == (s.location == "global")
])
error_message = "VPC spokes must set location = \"global\"; vpn_tunnels/interconnect/router_appliance spokes must set a region."
}
validation {
# The matching linked_* object must be present for the chosen type.
condition = alltrue([
for s in values(var.spokes) :
(s.type == "vpc" ? s.linked_vpc_network != null : true) &&
(s.type == "vpn_tunnels" ? s.linked_vpn_tunnels != null : true) &&
(s.type == "interconnect" ? s.linked_interconnect_attachments != null : true) &&
(s.type == "router_appliance" ? s.linked_router_appliance_instances != null : true)
])
error_message = "Each spoke must define the linked_* block matching its type (e.g. type=vpn_tunnels requires linked_vpn_tunnels)."
}
}
# outputs.tf
output "hub_id" {
description = "The fully-qualified ID of the Network Connectivity hub (projects/.../locations/global/hubs/<name>). Use this as the `hub` reference when adding spokes elsewhere."
value = google_network_connectivity_hub.this.id
}
output "hub_name" {
description = "The short name of the hub."
value = google_network_connectivity_hub.this.name
}
output "hub_state" {
description = "Current lifecycle state of the hub (e.g. ACTIVE)."
value = google_network_connectivity_hub.this.state
}
output "hub_unique_id" {
description = "Server-generated immutable unique ID of the hub, stable across renames."
value = google_network_connectivity_hub.this.unique_id
}
output "hub_route_tables" {
description = "List of route tables associated with the hub (populated for CUSTOM policy_mode)."
value = google_network_connectivity_hub.this.route_tables
}
output "spoke_ids" {
description = "Map of logical spoke key -> fully-qualified spoke ID."
value = { for k, s in google_network_connectivity_spoke.this : k => s.id }
}
output "spoke_states" {
description = "Map of logical spoke key -> spoke state (ACTIVE, INACTIVE, or pending acceptance)."
value = { for k, s in google_network_connectivity_spoke.this : k => s.state }
}
output "spoke_unique_ids" {
description = "Map of logical spoke key -> server-generated unique_id, useful for stable monitoring and IAM references."
value = { for k, s in google_network_connectivity_spoke.this : k => s.unique_id }
}
How to use it
module "network_connectivity_hub" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-network-connectivity-hub?ref=v1.0.0"
project_id = "kloudvin-prod-net"
name = "ncc-hub-prod"
description = "Prod transit fabric: shared VPC + spoke VPCs + on-prem HA VPN"
labels = { env = "prod", team = "platform-net" }
export_psc = true
# Spoke VPCs from these projects join without manual approval.
auto_accept_project_ids = ["kloudvin-prod-app", "kloudvin-prod-data"]
spokes = {
# 1) The shared/hub VPC itself, joined as a global VPC spoke.
shared_vpc = {
name = "spoke-shared-vpc"
location = "global"
type = "vpc"
linked_vpc_network = {
uri = google_compute_network.shared_vpc.id
# Keep the GKE master CIDR out of the mesh advertisements.
exclude_export_ranges = ["172.16.0.0/28"]
}
}
# 2) An application-tier VPC in another project (auto-accepted above).
app_vpc = {
name = "spoke-app-vpc"
location = "global"
type = "vpc"
linked_vpc_network = {
uri = "projects/kloudvin-prod-app/global/networks/vpc-app"
}
}
# 3) On-prem reachability via two HA VPN tunnels in asia-south1.
onprem_vpn = {
name = "spoke-onprem-vpn-as1"
location = "asia-south1"
type = "vpn_tunnels"
linked_vpn_tunnels = {
uris = [
google_compute_vpn_tunnel.onprem0.id,
google_compute_vpn_tunnel.onprem1.id,
]
site_to_site_data_transfer = true
}
}
}
}
# Downstream: a Cloud Router BGP peer that we only stand up once the on-prem
# VPN spoke has been admitted to the hub. The module's spoke_states map gates it.
resource "google_compute_router_peer" "onprem_bgp" {
count = module.network_connectivity_hub.spoke_states["onprem_vpn"] == "ACTIVE" ? 1 : 0
name = "peer-onprem-tunnel0"
project = "kloudvin-prod-net"
region = "asia-south1"
router = google_compute_router.onprem.name
interface = google_compute_router_interface.onprem0.name
peer_ip_address = "169.254.10.2"
peer_asn = 64600
advertised_route_priority = 100
}
output "ncc_hub_id" {
value = module.network_connectivity_hub.hub_id
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "gcs"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...gcs state bucket/container + key per path...
}
}
2. Module config — live/prod/network_connectivity_hub/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-network-connectivity-hub?ref=v1.0.0"
}
inputs = {
project_id = "..."
name = "..."
spokes[*].name = "..."
spokes[*].location = "..."
spokes[*].type = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/network_connectivity_hub && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| project_id | string | — | yes | GCP project ID that owns the hub and its spokes. |
| name | string | — | yes | Hub name (1-63 chars, lowercase RFC1035), unique in the project. |
| description | string | null | no | Optional description for the hub. |
| labels | map(string) | {} | no | Labels on the hub for cost allocation and ownership. |
| export_psc | bool | false | no | Whether the hub exports Private Service Connect propagated connections to spokes. |
| policy_mode | string | null | no | PRESET (any-to-any) or CUSTOM (route-table driven); null = provider default. |
| auto_accept_project_ids | list(string) | [] | no | Projects whose spokes auto-attach without manual approval. |
| spokes | map(object) | {} | no | Map of spokes keyed by logical name; each sets exactly one linked_* block per type (vpc / vpn_tunnels / interconnect / router_appliance). |
| spokes[*].name | string | — | yes | Spoke resource name. |
| spokes[*].location | string | — | yes | “global” for VPC spokes; the region for hybrid spokes. |
| spokes[*].type | string | — | yes | One of vpc, vpn_tunnels, interconnect, router_appliance. |
| spokes[*].linked_vpc_network | object | null | cond. | VPC spoke: uri plus optional include/exclude_export_ranges. |
| spokes[*].linked_vpn_tunnels | object | null | cond. | VPN spoke: uris, site_to_site_data_transfer, optional include_import_ranges. |
| spokes[*].linked_interconnect_attachments | object | null | cond. | Interconnect spoke: uris, site_to_site_data_transfer, optional include_import_ranges. |
| spokes[*].linked_router_appliance_instances | object | null | cond. | NVA spoke: site_to_site_data_transfer plus a list of { virtual_machine, ip_address }. |
Outputs
| Name | Description |
|---|---|
| hub_id | Fully-qualified hub ID (projects/…/locations/global/hubs/<name>); use as the hub reference for external spokes. |
| hub_name | Short name of the hub. |
| hub_state | Current lifecycle state of the hub (e.g. ACTIVE). |
| hub_unique_id | Immutable server-generated unique ID, stable across renames. |
| hub_route_tables | Route tables associated with the hub (populated for CUSTOM policy_mode). |
| spoke_ids | Map of logical spoke key to fully-qualified spoke ID. |
| spoke_states | Map of logical spoke key to spoke state (ACTIVE / INACTIVE / pending). |
| spoke_unique_ids | Map of logical spoke key to server-generated unique_id for monitoring and IAM. |
Enterprise scenario
A media enterprise runs a Shared VPC landing zone plus four independently-owned product VPCs across asia-south1 and europe-west1, and previously stitched them together with a brittle web of non-transitive VPC peerings that could not reach on-prem. They instantiate this module once to create ncc-hub-prod, register the shared VPC and all four product VPCs as global VPC spokes (with auto_accept_project_ids listing the product projects so each team’s spoke joins without a ticket), and attach two regional HA VPN spokes for the Mumbai and Frankfurt data centres. The hub now gives any-to-any transitive routing between every VPC and both on-prem sites, and the spoke_states output gates each region’s BGP peer so Terraform only programs routes after the corresponding spoke is ACTIVE.
Best practices
- Keep the hub owner as the route-policy gatekeeper. Only auto-accept projects you genuinely trust; for everything else leave
auto_accept_project_idsempty so cross-project spokes land in a pending state and the network team explicitly admits them — an open hub is a lateral-movement path between business units. - Set
locationcorrectly per spoke type. VPC spokes are global (location = "global"); VPN, Interconnect, and Router Appliance spokes are regional and must name the region their underlying tunnels, VLAN attachments, or NVAs live in. The module validates this, but getting it wrong is the single most common NCC apply failure. - Use
exclude_export_ranges/include_export_rangesto avoid leaking CIDRs. GKE master ranges, PSC subnets, and management CIDRs usually should not propagate across the whole mesh — scope exports deliberately rather than flooding every spoke with every route. - Mind per-spoke and data-transfer cost. NCC bills per spoke and, for hybrid spokes with
site_to_site_data_transfer = true, for inter-region and on-prem data transfer through the hub. Consolidate sites into fewer spokes where you can, and only enable site-to-site transfer where transit between hybrid endpoints is actually required. - Reference spokes by the
unique_id, not the name, in monitoring and IAM. Names can be recreated; the server-generatedunique_idis stable, which keeps dashboards and Cloud Monitoring uptime checks pointed at the right object across rebuilds. - Name with a clear
ncc-hub-<env>/spoke-<purpose>-<region>convention and one hub per environment. A single audited hub per env (prod, non-prod) beats per-team hubs that fragment the mesh and force re-peering; the consistent naming keeps spoke acceptance reviews and route audits readable.