IaC GCP

Terraform Module: GCP Network Connectivity Hub — a single hub-and-spoke fabric for VPCs and hybrid links

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

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 configlive/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 configlive/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

TerraformGCPNetwork Connectivity HubModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading