Google Cloud VPC Peering - Hub and Spoke Setup Step by Step with gcloud and Terraform


In this blog post we are going to look at Google Cloud VPC Network Peering - what it is, when you should use it instead of Shared VPC or a VPN, and then we are gonna build a complete hub-and-spoke lab step by step. Two VPC networks, two VMs with no public IP at all, and by the end of the post the hub VM pings the spoke VM over an internal IP, privately over Google's backbone.

I have been running this exact lab on my YouTube channel, so every command and every Terraform resource you see here is the one from the video. I have also added the step-by-step diagrams which I used for recording - click on any of them to zoom, and use the arrow keys to walk through the steps.

This is the first post of my Google Cloud networking series. Here is what we are going to cover -

Table of Content

  1. What is Google Cloud VPC Peering?
  2. VPC Peering vs Shared VPC vs Cloud VPN - when to use which
  3. What we are going to build - hub and spoke
  4. Build it step by step (diagrams, gcloud and Terraform for every step)
  5. How VPC peering works - concept animation
  6. gcloud CLI walkthrough - the complete command list
  7. The same hub and spoke in Terraform
  8. The transitive peering trap
  9. Conclusion and what to read next



1. What is Google Cloud VPC Peering?

VPC Network Peering gives you private connectivity between two VPC networks, so that the resources in both networks can reach each other using their internal IP addresses. The traffic stays on Google's backbone and never touches the public internet. No VPN tunnels, no gateways, no public IPs.

Think of it like this - you have two separate offices, each with its own private network. Peering is like running a private cable between the two buildings. Both offices keep their own address range, their own admins and their own firewall, but a machine in office A can now talk to a machine in office B as if they were on the same LAN.

A few properties of VPC peering which you must know before you start -

  1. It is bilateral - Both networks have to declare the peering. One side alone stays in INACTIVE state, it becomes ACTIVE only when the second side is created.
  2. No overlapping IP ranges - If hub-vpc uses 10.10.1.0/24, the spoke cannot use the same range. Google will reject the peering.
  3. Routes are exchanged, firewall rules are not - Peering makes the other network routable, but the firewall rules of each network still decide what is allowed. You need both. This is the number one reason why "my peering is ACTIVE but ping does not work".
  4. It is not transitive - If A is peered with B, and A is peered with C, then B still cannot talk to C. We will prove this in Section 8.
  5. It works across projects and even across organisations - as long as you have permission on your own side of the peering.


2. VPC Peering vs Shared VPC vs Cloud VPN - when to use which

This is the question I get asked the most in the comments, so let me put it in one table -

VPC PeeringShared VPCCloud VPN / Interconnect
What it connectsTwo separate VPC networksMany projects onto one networkA VPC to on-premises (or another cloud)
Who owns the networkEach side owns its ownOne host project owns it, service projects borrow subnetsEach side owns its own
Needs an Organization?NoYesNo
Transitive?NoNot applicable (one network)Depends on your routing
Typical useTwo teams, two independent networks, inside Google CloudEnterprise landing zone with a central network teamHybrid connectivity

So the rule of thumb is -

  • Two independently managed networks inside Google Cloud - VPC Peering (this post).
  • One governed network that many teams should share - Shared VPC, which needs an Organization.
  • Expose just one service, not the whole network - Private Service Connect.
  • Connect to your data centre - Cloud VPN or Interconnect.


3. What we are going to build - hub and spoke

Here is the lab. One project cl-demo-sandbox, region europe-north2 (Stockholm) -

  • hub-vpc (10.10.1.0/24) with hub-vm
  • spoke1-vpc (10.20.1.0/24) with spoke1-vm
  • Both VMs are e2-micro with no external IP. We will SSH into them through IAP (Identity-Aware Proxy), so nothing is exposed to the internet.
  • Peering in both directions, the two firewall rules which make the traffic actually flow, and a ping from hub to spoke to prove it.
  • As a bonus, a spoke2-vpc (10.30.1.0/24) in a second project to show cross-project peering and the transitive trap.
🔍 Click to zoom Two VPCs, one private link — traffic never leaves Google's backboneVPC Network Peering · internal IPs only · no VPN, no gateways📁 PROJECT · CL-DEMO-SANDBOXhub-vpcVPC NETWORKhub-subneteurope-north2 · 10.10.1.0/24hub-vmspoke1-vpcVPC NETWORKspoke1-subneteurope-north2 · 10.20.1.0/24spoke1-vmACTIVEVPC Network Peeringroutes exchanged · bilateral🔒 Sign in free to watch the full 4-step walkthrough animation
The hub-and-spoke topology we are going to build - click to zoom

Prerequisites -

  1. A Google Cloud project with billing enabled. If you are completely new to Google Cloud, start with my post on how to set up a Virtual Machine on Google Cloud Platform, it covers creating the project and installing the gcloud CLI.
  2. The gcloud CLI authenticated with an Owner or Network Admin on the project.
  3. For the Terraform part - Terraform installed and the Google provider configured. My Terraform getting started guide has everything if you are new to it.


4. Build it step by step (diagrams, gcloud and Terraform for every step)

This is the part I am most excited about. Each step below has three things - the diagram of what exists after the step, the gcloud command for the step, and the Terraform for the same step. Click a step in the rail or use the Prev / Next buttons (or the ← → arrow keys). Click the diagram to see it in full screen.

🔍 Click to zoomSTEP 1 / 7Networks & subnetsTwo isolated VPCs in one project — nothing can talk yet📁 PROJECT · cl-demo-sandboxNEWhub-vpcVPC NETWORKhub-subneteurope-north2 · 10.10.1.0/24spoke1-vpcVPC NETWORKspoke1-subneteurope-north2 · 10.20.1.0/24
▶ CLI command for this step
gcloud compute networks create hub-vpc --subnet-mode=custom
gcloud compute networks subnets create hub-subnet \
  --network=hub-vpc --region=europe-north2 --range=10.10.1.0/24

gcloud compute networks create spoke1-vpc --subnet-mode=custom
gcloud compute networks subnets create spoke1-subnet \
  --network=spoke1-vpc --region=europe-north2 --range=10.20.1.0/24
✦ Terraform for this step
resource "google_compute_network" "hub" {
  name                    = "hub-vpc"
  auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "hub" {
  name          = "hub-subnet"
  network       = google_compute_network.hub.id
  region        = "europe-north2"
  ip_cidr_range = "10.10.1.0/24"
}
resource "google_compute_network" "spoke1" {
  name                    = "spoke1-vpc"
  auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "spoke1" {
  name          = "spoke1-subnet"
  network       = google_compute_network.spoke1.id
  region        = "europe-north2"
  ip_cidr_range = "10.20.1.0/24"
}
Step 1 of 7

The steps are cumulative - step 5 shows everything from steps 1 to 4 plus the peering. If you only want the full command list to copy and paste, jump to Section 6.


5. How VPC peering works - concept animation

Before you run the commands, here is a one-picture explanation of what Google does behind the scenes when you create a peering - the routes of each network get exported to the other, while the firewall of each network stays exactly as it was.

🔍 Click to zoom STEP 1 · create peering on hub-vpc → status: INACTIVESTEP 2 · create the mirror peering on the spokes → status: ACTIVESTEP 3 · routes exchanged + firewall rules → traffic flowsSTEP 4 · spoke1 → spoke2 via the hub? BLOCKED — peering is never transitive📁 PROJECT · CL-DEMO-SANDBOXhub-vpcVPC NETWORKhub-subneteurope-north2 · 10.10.1.0/24hub-vmspoke1-vpcVPC NETWORKspoke1-subneteurope-north2 · 10.20.1.0/24spoke1-vm📁 PROJECT · SECOND_PROJECT_IDspoke2-vpcVPC NETWORKspoke2-subneteurope-north2 · 10.30.1.0/24spoke2-vmINACTIVEACTIVEACTIVEroutes ✓ firewall ✓routes ✓ firewall ✓✕BLOCKED — no transitive peeringhub ↔ spoke trafficone-sided = INACTIVEnever transitive
Routes are exchanged by the peering, firewall rules are not - click to zoom


6. gcloud CLI walkthrough - the complete command list

Here is the complete walkthrough with gcloud. Copy these top to bottom and the hub-and-spoke works - every command matches the live demo in the video.

Copy these top to bottom and the hub-and-spoke works — every command matches the live demo. Project cl-demo-sandbox, region europe-north2 (Stockholm).

Set your project once and make sure the Compute Engine API is on:

1gcloud config set project cl-demo-sandbox
2gcloud services enable compute.googleapis.com

1 · Create the hub and spoke VPCs

1gcloud compute networks create hub-vpc --subnet-mode=custom
2gcloud compute networks subnets create hub-subnet \
3  --network=hub-vpc --region=europe-north2 --range=10.10.1.0/24
4
5gcloud compute networks create spoke1-vpc --subnet-mode=custom
6gcloud compute networks subnets create spoke1-subnet \
7  --network=spoke1-vpc --region=europe-north2 --range=10.20.1.0/24

2 · Create test VMs (no external IPs)

e2-micro is the cheapest machine type; --no-address means no public IP.

1gcloud compute instances create hub-vm \
2  --zone=europe-north2-a --subnet=hub-subnet --no-address \
3  --machine-type=e2-micro --image-family=debian-13 --image-project=debian-cloud
4gcloud compute instances create spoke1-vm \
5  --zone=europe-north2-a --subnet=spoke1-subnet --no-address \
6  --machine-type=e2-micro --image-family=debian-13 --image-project=debian-cloud

3 · Reach the VMs with no public IP (IAP SSH)

These VMs have --no-address, so there's no public IP to SSH to. The console's SSH button tunnels through IAP (Identity-Aware Proxy) instead: your browser → Google's IAP relay → the VM's internal IP. IAP always connects from Google's fixed range 35.235.240.0/20, so that's the source the firewall must allow (not your laptop's IP).

 1# 1. enable the IAP API (one-time, project-wide)
 2gcloud services enable iap.googleapis.com
 3
 4# 2. let IAP's range SSH into the hub
 5gcloud compute firewall-rules create hub-fw-allow-iap-ssh \
 6  --network=hub-vpc --direction=INGRESS --action=ALLOW \
 7  --rules=tcp:22 --source-ranges=35.235.240.0/20
 8
 9# 3. ...and into spoke1 (one rule per network whose VMs you SSH into)
10gcloud compute firewall-rules create spoke1-fw-allow-iap-ssh \
11  --network=spoke1-vpc --direction=INGRESS --action=ALLOW \
12  --rules=tcp:22 --source-ranges=35.235.240.0/20

You also need the IAP-secured Tunnel User role (roles/iap.tunnelResourceAccessor) — Owners and Editors already have it.

Now open hub-vm from the console's SSH button and ping the spoke by its internal IP (peer VM names don't resolve across peering, so always use the IP):

1ping -c 3 10.20.1.2      # hub-vm → spoke1-vm — fails for now (no peering yet). Good.

4 · Peer both sides — ACTIVE only after the second

Peering is bilateral: each side declares it, and it flips to ACTIVE only once both halves exist.

 1# side 1 — on hub-vpc
 2gcloud compute networks peerings create hub-to-spoke1 \
 3  --network=hub-vpc --peer-network=spoke1-vpc
 4
 5# side 2 — on spoke1-vpc
 6gcloud compute networks peerings create spoke1-to-hub \
 7  --network=spoke1-vpc --peer-network=hub-vpc
 8
 9# both entries must say ACTIVE
10gcloud compute networks peerings list --network=hub-vpc

5 · Firewall — routes make it routable, firewall makes it allowed

Naming that reads correctly: <network-it-lives-on>-fw-allow-from-<source>. A firewall rule always lives on the network that receives the traffic — you stand at your own door and decide who may knock. (Egress is implied-allow; ingress is implied-deny, so inbound rules are the only ones you need here.)

1# standing on spoke1 — let the hub in
2gcloud compute firewall-rules create spoke1-fw-allow-from-hub \
3  --network=spoke1-vpc --direction=INGRESS --action=ALLOW \
4  --rules=icmp,tcp:22 --source-ranges=10.10.1.0/24
5
6# standing on the hub — let spoke1 in
7gcloud compute firewall-rules create hub-fw-allow-from-spokes \
8  --network=hub-vpc --direction=INGRESS --action=ALLOW \
9  --rules=icmp,tcp:22 --source-ranges=10.20.1.0/24

Back on hub-vm, ping the spoke again — now it works:

1ping -c 3 10.20.1.2      # hub-vm → spoke1-vm — SUCCESS 🎉

Peering made it routable, the firewall made it allowed — you need both.

6 · Cross-project: spoke2 in a second project

This part needs a second GCP project (with billing). Replace your-second-project with its real ID everywhere below — everything above ran in cl-demo-sandbox only.

 1# build spoke2 in the second project
 2gcloud compute networks create spoke2-vpc --subnet-mode=custom --project=your-second-project
 3gcloud compute networks subnets create spoke2-subnet --project=your-second-project \
 4  --network=spoke2-vpc --region=europe-north2 --range=10.30.1.0/24
 5gcloud compute instances create spoke2-vm --project=your-second-project \
 6  --zone=europe-north2-a --subnet=spoke2-subnet --no-address \
 7  --machine-type=e2-micro --image-family=debian-13 --image-project=debian-cloud
 8
 9# peer hub <-> spoke2 — one side in each project
10gcloud compute networks peerings create hub-to-spoke2 --project=cl-demo-sandbox \
11  --network=hub-vpc --peer-network=spoke2-vpc --peer-project=your-second-project
12gcloud compute networks peerings create spoke2-to-hub --project=your-second-project \
13  --network=spoke2-vpc --peer-network=hub-vpc --peer-project=cl-demo-sandbox
14
15# the hub rule already allows spoke1 — ADD spoke2's range to the same rule
16gcloud compute firewall-rules update hub-fw-allow-from-spokes --project=cl-demo-sandbox \
17  --source-ranges=10.20.1.0/24,10.30.1.0/24
18
19# standing on spoke2 — let the hub in, and let IAP SSH you in to test
20gcloud compute firewall-rules create spoke2-fw-allow-from-hub --project=your-second-project \
21  --network=spoke2-vpc --direction=INGRESS --action=ALLOW \
22  --rules=icmp,tcp:22 --source-ranges=10.10.1.0/24
23gcloud compute firewall-rules create spoke2-fw-allow-iap-ssh --project=your-second-project \
24  --network=spoke2-vpc --direction=INGRESS --action=ALLOW \
25  --rules=tcp:22 --source-ranges=35.235.240.0/20

Now the two tests that prove the point:

  • hub-vm → spoke2-vm (ping 10.30.1.2) → works — the hub peers with spoke2.
  • spoke1-vm → spoke2-vm (ping 10.30.1.2) → fails, even though both peer with the hub.

The trap: peering is never transitive. A↔B and A↔C never give you B↔C.


7. The same hub and spoke in Terraform

Everything we did by hand in Section 6 as Terraform code. One terraform apply builds the networks, the subnets, the VMs, the peering in both directions and the firewall rules.

If you have not worked with the Google provider before, the only thing you need is a provider "google" block with your project and region, and credentials - either gcloud auth application-default login on your laptop or a service account key. Watch out for the invalid function argument credential file error if you go the service account route, I have a separate post on it.

The same hub-and-spoke as code — one terraform apply builds everything the CLI steps do. Region europe-north2, project cl-demo-sandbox. terraform sorts out the order itself, so these files can live side by side.

providers.tf

1provider "google" {
2  project = "cl-demo-sandbox"
3  region  = "europe-north2"
4}

networks.tf

 1resource "google_compute_network" "hub" {
 2  name                    = "hub-vpc"
 3  auto_create_subnetworks = false
 4}
 5
 6resource "google_compute_subnetwork" "hub" {
 7  name          = "hub-subnet"
 8  network       = google_compute_network.hub.id
 9  region        = "europe-north2"
10  ip_cidr_range = "10.10.1.0/24"
11}
12
13resource "google_compute_network" "spoke1" {
14  name                    = "spoke1-vpc"
15  auto_create_subnetworks = false
16}
17
18resource "google_compute_subnetwork" "spoke1" {
19  name          = "spoke1-subnet"
20  network       = google_compute_network.spoke1.id
21  region        = "europe-north2"
22  ip_cidr_range = "10.20.1.0/24"
23}

instances.tf — no external IP

An empty access_config would give the VM a public IP; omitting the block entirely is what makes it internal-only.

 1resource "google_compute_instance" "hub" {
 2  name         = "hub-vm"
 3  zone         = "europe-north2-a"
 4  machine_type = "e2-micro"
 5
 6  boot_disk {
 7    initialize_params {
 8      image = "debian-cloud/debian-13"
 9    }
10  }
11
12  network_interface {
13    subnetwork = google_compute_subnetwork.hub.id
14    # no access_config → no external IP
15  }
16}
17
18resource "google_compute_instance" "spoke1" {
19  name         = "spoke1-vm"
20  zone         = "europe-north2-a"
21  machine_type = "e2-micro"
22
23  boot_disk {
24    initialize_params {
25      image = "debian-cloud/debian-13"
26    }
27  }
28
29  network_interface {
30    subnetwork = google_compute_subnetwork.spoke1.id
31  }
32}

peering.tf — both directions

 1resource "google_compute_network_peering" "hub_to_spoke1" {
 2  name         = "hub-to-spoke1"
 3  network      = google_compute_network.hub.self_link
 4  peer_network = google_compute_network.spoke1.self_link
 5}
 6
 7resource "google_compute_network_peering" "spoke1_to_hub" {
 8  name         = "spoke1-to-hub"
 9  network      = google_compute_network.spoke1.self_link
10  peer_network = google_compute_network.hub.self_link
11}

firewall.tf

Two kinds of rule: IAP SSH (so you can reach the VMs with no public IP, from Google's 35.235.240.0/20 range) and the hub↔spoke rules (so the VMs can reach each other).

 1# --- IAP SSH: reach each VM from the console with no public IP ---
 2resource "google_compute_firewall" "hub_fw_allow_iap_ssh" {
 3  name          = "hub-fw-allow-iap-ssh"
 4  network       = google_compute_network.hub.id
 5  direction     = "INGRESS"
 6  source_ranges = ["35.235.240.0/20"]
 7  allow {
 8    protocol = "tcp"
 9    ports    = ["22"]
10  }
11}
12
13resource "google_compute_firewall" "spoke1_fw_allow_iap_ssh" {
14  name          = "spoke1-fw-allow-iap-ssh"
15  network       = google_compute_network.spoke1.id
16  direction     = "INGRESS"
17  source_ranges = ["35.235.240.0/20"]
18  allow {
19    protocol = "tcp"
20    ports    = ["22"]
21  }
22}
23
24# --- hub <-> spoke: lives on the network that RECEIVES the traffic ---
25# lives on spoke1 — lets the hub in
26resource "google_compute_firewall" "spoke1_fw_allow_from_hub" {
27  name          = "spoke1-fw-allow-from-hub"
28  network       = google_compute_network.spoke1.id
29  direction     = "INGRESS"
30  source_ranges = ["10.10.1.0/24"]
31  allow {
32    protocol = "icmp"
33  }
34  allow {
35    protocol = "tcp"
36    ports    = ["22"]
37  }
38}
39
40# lives on the hub — lets spoke1 in (add "10.30.1.0/24" when spoke2 exists)
41resource "google_compute_firewall" "hub_fw_allow_from_spokes" {
42  name          = "hub-fw-allow-from-spokes"
43  network       = google_compute_network.hub.id
44  direction     = "INGRESS"
45  source_ranges = ["10.20.1.0/24"]
46  allow {
47    protocol = "icmp"
48  }
49  allow {
50    protocol = "tcp"
51    ports    = ["22"]
52  }
53}

terraform apply, then SSH into hub-vm from the console and ping -c 3 10.20.1.2 — it reaches spoke1-vm over internal IPs. (Ping the IP, not the name: peer VM names don't resolve across peering.)

Cross-project (spoke2)

spoke2 lives in a second project, so you add a second google provider aliased to it and scope spoke2's resources with provider = google.project2. Peering still needs a resource on each side, and each side needs permissions on its own project.

 1provider "google" {
 2  alias   = "project2"
 3  project = "your-second-project"
 4  region  = "europe-north2"
 5}
 6
 7# hub side (default project)
 8resource "google_compute_network_peering" "hub_to_spoke2" {
 9  name         = "hub-to-spoke2"
10  network      = google_compute_network.hub.self_link
11  peer_network = "projects/your-second-project/global/networks/spoke2-vpc"
12}
13# ...and the mirror "spoke2_to_hub" created with provider = google.project2,
14# plus spoke2's own subnet, VM, and firewall rules under that provider.

Then widen the hub rule to include spoke2's range — change hub_fw_allow_from_spokes to source_ranges = ["10.20.1.0/24", "10.30.1.0/24"] and re-apply.

Tip: one terraform apply creates both peering directions at once — but each google_compute_network_peering still needs permissions on its own network's project.

Tip - Keep this project in its own Terraform state. The spoke projects of a real hub-and-spoke are usually managed by different teams, and they only need the hub's network self_link as an input, which you can share through a terraform_remote_state data source or a plain Terraform variable.


8. The transitive peering trap

Here is the thing which catches everyone the first time. After step 7 we have -

  • hub-vpc peered with spoke1-vpc
  • hub-vpc peered with spoke2-vpc

So you would expect spoke1-vm to reach spoke2-vm through the hub. It does not.

1# from hub-vm   -> spoke2-vm : works   (hub peers with spoke2)
2ping -c 3 10.30.1.2
3
4# from spoke1-vm -> spoke2-vm : FAILS  (both peer with the hub, but not with each other)
5ping -c 3 10.30.1.2

VPC peering is never transitive. A ↔ B and A ↔ C never give you B ↔ C, and no firewall rule can fix it, because there is simply no route from spoke1 to spoke2. If you really need spoke-to-spoke traffic you have three options -

  1. Peer the spokes directly with each other (a full mesh - fine for 3 networks, a nightmare for 30).
  2. Use Network Connectivity Center with the hub as a transit hub.
  3. Put the workloads on one network instead - which is exactly what Shared VPC is for.

I have written the cross-project part of this lab up as its own post, with the two-provider Terraform pattern - Google Cloud cross-project VPC peering.


VPC Network Peering is the simplest way to connect two VPC networks on Google Cloud, and now you have seen the whole thing - the concept, the hub-and-spoke build with diagrams for each step, the gcloud commands, the Terraform, and the transitive trap.

The three things to remember -

  1. Peering is bilateral - create it on both sides, and check for ACTIVE.
  2. Routes are exchanged, firewall rules are not - a firewall rule always lives on the network that receives the traffic.
  3. Peering is not transitive - for many networks, look at Shared VPC or Network Connectivity Center.

Here is the rest of the Google Cloud networking series -

  1. Google Cloud cross-project VPC peering - two projects, two providers
  2. Google Cloud Organization setup with Cloud Identity (free) - needed before Shared VPC
  3. Google Cloud Shared VPC - one network, many projects
  4. Google Cloud Private Service Connect - publish one service privately

If you have any questions or you are stuck somewhere, put them in the comments and I will try to help.

Posts in this series