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:
- Peering is one-directional in the API, but only works when both sides exist. A
google_compute_network_peeringresource describes the connection from one network to another. Creating it on network A alone leaves the peering in anINACTIVEstate — the connection does not carry traffic until a matching resource is created on network B pointing back at A. You must always create two resources, one per direction, and their route-export/import settings should be set deliberately on each side. - Peering is non-transitive. If A peers with B and B peers with C, A cannot reach C through B. There is no route propagation across a peering hop. A hub-and-spoke built on raw peering needs a full mesh, or a different primitive (Network Connectivity Center, or routing appliances in the hub) for transitivity. Plan your topology around this — it is the single most common surprise.
- CIDR ranges must not overlap. Two networks whose subnet ranges overlap cannot be peered; GCP rejects the connection. By default a peering exchanges subnet routes automatically, but custom routes (static routes, dynamic routes learned by Cloud Router) are not exchanged unless you explicitly turn on
export_custom_routes/import_custom_routes— and you must agree on those settings symmetrically across the two sides.
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
- You are connecting a hub VPC to a spoke VPC (or a shared services VPC to a workload VPC) and want both directions created and kept in sync from one module call.
- You need cross-project or cross-organization private connectivity between two VPCs you control, where the CIDRs are already planned to not overlap.
- You want to share custom routes (e.g. a default route through a NAT/NVA in the hub, or on-prem routes learned via Cloud Router) across the peering, and need the
export/importflags set consistently on both sides. - You want a paved-road peering module so teams stop hand-rolling a single-direction
google_compute_network_peeringand leaving the connectionINACTIVE.
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.addPeeringon both projects. A peering whose far side has not been created (or which you lack permission to create) staysINACTIVE— check thestateoutput, 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 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/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
- Always create both directions — this module does it for you. A single-sided peering stays
INACTIVEand silently carries no traffic; check thestateoutput after apply rather than trusting a clean plan. - Plan non-overlapping CIDRs before you peer. GCP rejects peerings between networks with overlapping subnet ranges. Allocate ranges from a central IPAM so every VPC you might ever peer has a unique supernet.
- Set custom-route export/import deliberately and symmetrically. Custom routes are not exchanged by default; this module mirrors the A-side
export/importonto the B side so the two halves agree. Only enable them when you actually want to share static/dynamic routes (e.g. a default route through a hub NVA). - Remember peering is non-transitive. Do not design a hub-and-spoke expecting spokes to reach each other through the hub. Either build a full mesh of peerings, route through a shared service/appliance in the hub, or switch to Network Connectivity Center for true transitivity.
- Keep public-IP subnet-route exchange off unless required. Leave
export_subnet_routes_with_public_ip/import_subnet_routes_with_public_ipdisabled for private-only connectivity; enabling them can pull in routes you did not intend to advertise across the peering. - Grant peering permissions on both projects and pin the module. Cross-project peering needs
compute.networks.addPeeringon each side; pin with?ref=<tag>so a network’s connectivity is never altered by an unreviewed module change.
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.