IaC GCP

Terraform Module: GCP VPC Network Peering — Both Sides Wired in One Reviewed Change

Quick take — A reusable hashicorp/google ~> 5.0 module for google_compute_network_peering: creates both directions of a VPC peering with symmetric custom-route export/import, public-IP subnet-route control, and clean outputs — non-transitive peering done right. 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 "vpc_peering" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-vpc-network-peering?ref=v1.0.0"

  name         = "..."  # Base name for the peering; -a/-b suffix per direction.
  network      = "..."  # Self-link or name of the local ("A") network.
  peer_network = "..."  # Self-link or name of the remote ("B") network.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

A Google Cloud VPC Network Peering (google_compute_network_peering) connects two VPC networks so that resources in each can reach the other over Google’s internal backbone using private IPs — no VPN tunnel, no external IPs, no bandwidth bottleneck. It is the standard way to stitch a hub VPC to spoke VPCs, share a services VPC across teams, or join two networks that belong to different projects (even different organizations).

Three properties of GCP peering shape everything about how you wire it, and each is a place people get burned:

This module wraps the pair of google_compute_network_peering resources so a peering is one reviewed change that creates both directions at once, with the export/import flags expressed per side and validated to be consistent. It turns a connection that is easy to half-configure (create one side, forget the other, stare at an INACTIVE peering) into a single, symmetric, versioned building block.

When to use it

Reach for Network Connectivity Center instead when you need a transitive hub that lets many spokes reach each other through a central VPC — raw peering cannot do that. Use Cloud VPN / Interconnect when one side is on-premises or in another cloud, and Private Service Connect when you are consuming a managed service rather than joining two of your own VPCs.

Module structure

terraform-module-gcp-vpc-network-peering/
├── 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 {
  # Deterministic per-direction names so both halves are easy to find/destroy.
  name_a_to_b = coalesce(var.name_a_to_b, "${var.name}-a-to-b")
  name_b_to_a = coalesce(var.name_b_to_a, "${var.name}-b-to-a")
}

# Direction A -> B: described on the local ("A") network, pointing at B.
resource "google_compute_network_peering" "a_to_b" {
  name         = local.name_a_to_b
  network      = var.network
  peer_network = var.peer_network

  # Custom routes (static + Cloud Router dynamic) are NOT exchanged unless
  # explicitly enabled. Keep these symmetric with the b_to_a side.
  export_custom_routes = var.export_custom_routes
  import_custom_routes = var.import_custom_routes

  # By default, subnet routes whose next hop has a public IP are not exchanged.
  # Enable only when you intentionally rely on public-IP subnet routes.
  export_subnet_routes_with_public_ip = var.export_subnet_routes_with_public_ip
  import_subnet_routes_with_public_ip = var.import_subnet_routes_with_public_ip

  # Stack type controls whether IPv4-only or dual-stack routes are exchanged.
  stack_type = var.stack_type
}

# Direction B -> A: the matching half. Without this the peering stays INACTIVE.
# Note the network/peer_network are swapped, and import/export mirror the A side.
resource "google_compute_network_peering" "b_to_a" {
  name         = local.name_b_to_a
  network      = var.peer_network
  peer_network = var.network

  export_custom_routes = var.import_custom_routes
  import_custom_routes = var.export_custom_routes

  export_subnet_routes_with_public_ip = var.import_subnet_routes_with_public_ip
  import_subnet_routes_with_public_ip = var.export_subnet_routes_with_public_ip

  stack_type = var.stack_type

  # Create A's half first so the connection activates as soon as B's half lands.
  depends_on = [google_compute_network_peering.a_to_b]
}

variables.tf

variable "name" {
  type        = string
  description = "Base name for the peering. Each direction is suffixed (-a-to-b / -b-to-a) unless overridden."

  validation {
    condition     = can(regex("^[a-z]([-a-z0-9]{0,55}[a-z0-9])?$", var.name))
    error_message = "name must be RFC1035: lowercase, start with a letter, end alphanumeric, max ~57 chars to leave room for the suffix."
  }
}

variable "name_a_to_b" {
  type        = string
  description = "Explicit name for the A->B peering. If null, derived as \"<name>-a-to-b\"."
  default     = null
}

variable "name_b_to_a" {
  type        = string
  description = "Explicit name for the B->A peering. If null, derived as \"<name>-b-to-a\"."
  default     = null
}

variable "network" {
  type        = string
  description = "Self-link (preferred) or name of the local (\"A\") VPC network. CIDRs must not overlap peer_network."

  validation {
    condition     = length(var.network) > 0
    error_message = "network must be a non-empty network self-link or name."
  }
}

variable "peer_network" {
  type        = string
  description = "Self-link (preferred) or name of the remote (\"B\") VPC network. CIDRs must not overlap network."

  validation {
    condition     = length(var.peer_network) > 0
    error_message = "peer_network must be a non-empty network self-link or name."
  }
}

variable "export_custom_routes" {
  type        = bool
  description = "Whether the A side exports its custom (static + dynamic) routes to B. The B side imports them automatically."
  default     = false
}

variable "import_custom_routes" {
  type        = bool
  description = "Whether the A side imports custom routes from B. The B side exports them automatically."
  default     = false
}

variable "export_subnet_routes_with_public_ip" {
  type        = bool
  description = "Export subnet routes whose next hop uses a public IP from A to B. Leave false unless you rely on public-IP subnet routes."
  default     = true
}

variable "import_subnet_routes_with_public_ip" {
  type        = bool
  description = "Import subnet routes whose next hop uses a public IP from B to A. Leave false unless you rely on public-IP subnet routes."
  default     = false
}

variable "stack_type" {
  type        = string
  description = "IP stack of routes exchanged: IPV4_ONLY or IPV4_IPV6."
  default     = "IPV4_ONLY"

  validation {
    condition     = contains(["IPV4_ONLY", "IPV4_IPV6"], var.stack_type)
    error_message = "stack_type must be IPV4_ONLY or IPV4_IPV6."
  }
}

outputs.tf

output "peering_a_to_b_name" {
  description = "Name of the A->B peering resource."
  value       = google_compute_network_peering.a_to_b.name
}

output "peering_b_to_a_name" {
  description = "Name of the B->A peering resource."
  value       = google_compute_network_peering.b_to_a.name
}

output "state" {
  description = "State of the A->B peering (ACTIVE once both directions exist, otherwise INACTIVE)."
  value       = google_compute_network_peering.a_to_b.state
}

output "state_details" {
  description = "Human-readable details about the A->B peering state."
  value       = google_compute_network_peering.a_to_b.state_details
}

output "peer_state" {
  description = "State of the B->A peering."
  value       = google_compute_network_peering.b_to_a.state
}

How to use it

# Two custom-mode VPCs with non-overlapping CIDRs, in the same or different projects.
resource "google_compute_network" "hub" {
  project                 = "kv-prod-host-9f3a"
  name                    = "prod-hub"
  auto_create_subnetworks = false
}

resource "google_compute_network" "spoke" {
  project                 = "kv-prod-app-1c4d"
  name                    = "prod-app-spoke"
  auto_create_subnetworks = false
}

module "vpc_peering" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-vpc-network-peering?ref=v1.0.0"

  name         = "hub-to-app"
  network      = google_compute_network.hub.self_link   # A side
  peer_network = google_compute_network.spoke.self_link # B side

  # Share the hub's custom routes (e.g. a default route via the NVA) with the
  # spoke, and let the hub import the spoke's custom routes. Symmetric by design.
  export_custom_routes = true
  import_custom_routes = true

  # Do not exchange public-IP subnet routes — private connectivity only.
  export_subnet_routes_with_public_ip = false
  import_subnet_routes_with_public_ip = false
}

# Downstream: confirm the peering came up ACTIVE before depending on it.
output "hub_app_peering_state" {
  value = module.vpc_peering.state
}

When the two networks live in different projects, the principal running Terraform needs compute.networks.addPeering on both projects. A peering whose far side has not been created (or which you lack permission to create) stays INACTIVE — check the state output, not just a green apply.

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/vpc_peering/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-vpc-network-peering?ref=v1.0.0"
}

inputs = {
  name = "..."
  network = "..."
  peer_network = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/vpc_peering && 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
name string Yes Base name for the peering; each direction is suffixed.
name_a_to_b string null No Explicit A→B peering name (overrides the derived name).
name_b_to_a string null No Explicit B→A peering name (overrides the derived name).
network string Yes Self-link/name of the local (“A”) network; CIDRs must not overlap.
peer_network string Yes Self-link/name of the remote (“B”) network; CIDRs must not overlap.
export_custom_routes bool false No A exports its custom routes to B (B imports them).
import_custom_routes bool false No A imports custom routes from B (B exports them).
export_subnet_routes_with_public_ip bool true No Exchange subnet routes whose next hop uses a public IP (A→B).
import_subnet_routes_with_public_ip bool false No Import subnet routes whose next hop uses a public IP (B→A).
stack_type string IPV4_ONLY No IP stack of routes exchanged: IPV4_ONLY or IPV4_IPV6.

Outputs

Name Description
peering_a_to_b_name Name of the A→B peering resource.
peering_b_to_a_name Name of the B→A peering resource.
state State of the A→B peering (ACTIVE once both halves exist).
state_details Human-readable details about the A→B peering state.
peer_state State of the B→A peering.

Enterprise scenario

A SaaS company runs a shared services VPC (prod-hub) in a platform project, hosting centralized DNS forwarders, a logging sink, and an egress NVA. Each product team gets its own workload VPC in its own project, peered to the hub with this module. Because the team only writes one module "vpc_peering" block, both directions are created together and the connection comes up ACTIVE on the first apply instead of sitting half-wired. The hub exports its custom default route through the NVA (export_custom_routes = true) so every spoke egresses through the inspected path, while public-IP subnet-route exchange is left off to keep traffic private. When a new team onboards, the platform team reviews a single PR: a non-overlapping CIDR plan plus one peering module call. The team also documents that the topology is a hub-and-spoke of peerings, not a mesh — spokes cannot reach each other through the hub, so anything cross-team goes through a shared service in the hub rather than direct spoke-to-spoke routing.

Best practices


Part of the KloudVin Terraform module library. Pair this with the VPC Network, Subnet/Firewall, and Cloud Router modules — feed their self_link outputs straight into this module’s network and peer_network.

TerraformGCPVPC PeeringModuleIaC
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