Google Cloud Cross-Project VPC Peering - Peer Two VPCs in Different Projects (gcloud and Terraform)


In the previous post on Google Cloud VPC peering we built a hub-and-spoke inside a single project. But in a real company that is almost never how it looks. The network team owns a hub in one project, and every application team has its own project with its own VPC. So in this blog post we are going to peer two VPC networks which live in different projects.

The good news - it is the same peering primitive. The three things which change are the --peer-project flag, the fact that each side is created in its own project with its own permissions, and the Terraform, where we need two providers. And at the end we will hit the transitive trap again, which bites much harder across projects.

Table of Content

  1. What is cross-project VPC peering and how is it different?
  2. What we are going to build
  3. Build it step by step (diagrams, gcloud and Terraform for every step)
  4. gcloud CLI walkthrough - the complete command list
  5. Terraform - the two-provider pattern
  6. The transitive trap across projects and the real fix
  7. Common errors
  8. Conclusion and what to read next



1. What is cross-project VPC peering and how is it different?

Cross-project VPC peering connects two VPC networks that live in different Google Cloud projects - a shared-services hub in one project and a workload VPC in another - privately over Google's backbone, using internal IPs. It is the same VPC Network Peering as the same-project version, with three differences that matter -

  1. Each side is created in its own project, with --peer-project pointing at the other project. You run one command in project A and one command in project B.
  2. Neither project needs access to the other's VPC. You only need permission on your own project's network (Owner or Compute Network Admin there). This is what makes it work between two teams who do not trust each other with their networks.
  3. Non-transitivity still holds, and it bites harder across projects - a workload peered to the hub still cannot reach another workload peered to the same hub.

Everything else from the single-project post applies - no overlapping IP ranges, peering is bilateral, routes are exchanged but firewall rules are not.



2. What we are going to build

This continues from the VPC Peering post. You already have -

  • Project A cl-demo-sandbox with hub-vpc (10.10.1.0/24) and hub-vm, plus spoke1-vpc (10.20.1.0/24) and spoke1-vm

Here you will stand up -

  • Project B cl-demo-sandboxcli with spoke2-vpc (10.30.1.0/24) and spoke2-vm (no public IP, IAP SSH)
  • A peering between hub-vpc and spoke2-vpc across the project boundary, one side in each project
  • The firewall rules on both sides, and then the two pings which prove what works and what doesn't
🔍 Click to zoom Peer VPCs across two separate projects — privately, over Google's backboneSame peering primitive · one side created in each project · --peer-project📁 PROJECT · CL-DEMO-SANDBOXhub-vpchub-subneteurope-north2 · 10.10.1.0/24📁 PROJECT · CL-DEMO-SANDBOXCLIspoke2-vpcspoke2-subneteurope-north2 · 10.30.1.0/24ACTIVEcross-project peering🔒 Sign in free to watch the full 6-step build — with CLI + Terraform per step
The hub in project A peered with spoke2 in project B - click to zoom

Prerequisites - the hub from the previous post, and a second project with billing linked. Without billing you cannot create a VM in project B, which is the first thing most people trip over - we handle it in step 1.



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

Same format as the last post - click a step in the rail, or use Prev / Next or the arrow keys. Every step shows the diagram after the step, the gcloud command and the Terraform. Click the diagram to zoom.

🔍 Click to zoomSTEP 1 / 6Prepare project BLink billing + enable Compute/IAP APIs on cl-demo-sandboxcli📁 PROJECT · cl-demo-sandboxhub-vpcVPC NETWORKhub-subneteurope-north2 · 10.10.1.0/24hub-vm · 10.10.1.2SSH ✓hub-fw-allow-from-spokesicmp,tcp:22 ← 10.20.1.0/24spoke1-vpcVPC NETWORKspoke1-subneteurope-north2 · 10.20.1.0/24spoke1-vm · 10.20.1.2SSH ✓ACTIVE📁 PROJECT · cl-demo-sandboxcliNEW✅ billing linked · Compute + IAP APIs enabledThe one-time prerequisite before any resource can be created in project BNEW
▶ CLI command for this step
# link billing (any open billing account) + enable the APIs in project B
gcloud billing projects link cl-demo-sandboxcli \
  --billing-account=YOUR_BILLING_ACCOUNT_ID
gcloud services enable compute.googleapis.com iap.googleapis.com \
  --project=cl-demo-sandboxcli
✦ Terraform for this step
# one provider per project — this is the whole trick of cross-project
provider "google" {
  project = "cl-demo-sandbox"     # project A (default)
  region  = "europe-north2"
}
provider "google" {
  alias   = "workload"            # project B, addressed as google.workload
  project = "cl-demo-sandboxcli"
  region  = "europe-north2"
}
Step 1 of 6


4. gcloud CLI walkthrough - the complete command list

The exact commands, in order. Two projects - cl-demo-sandbox (project A, holds the existing hub-vpc) and cl-demo-sandboxcli (project B, the new workload project). Replace them with your own project IDs.

The exact commands, in order. Two projects: cl-demo-sandbox (project A — holds the existing hub-vpc) and cl-demo-sandboxcli (project B — the new workload project). Region europe-north2. Prereq: you've finished the VPC Peering hub, so hub-vpc + hub-vm already exist in project A.

1 · Prepare the second project

Compute Engine needs a billing account linked and its APIs enabled before you can create anything in project B.

1# link billing (any open billing account) so Compute can run in project B
2gcloud billing projects link cl-demo-sandboxcli \
3  --billing-account=YOUR_BILLING_ACCOUNT_ID
4
5# enable the APIs we need there
6gcloud services enable compute.googleapis.com iap.googleapis.com \
7  --project=cl-demo-sandboxcli

2 · Create the workload VPC + subnet (project B)

Non-overlapping range — 10.30.1.0/24, distinct from hub (10.10) and spoke1 (10.20).

1gcloud compute networks create spoke2-vpc --subnet-mode=custom --project=cl-demo-sandboxcli
2gcloud compute networks subnets create spoke2-subnet --project=cl-demo-sandboxcli \
3  --network=spoke2-vpc --region=europe-north2 --range=10.30.1.0/24

3 · Test VM + IAP SSH (project B)

1gcloud compute instances create spoke2-vm --project=cl-demo-sandboxcli \
2  --zone=europe-north2-a --subnet=spoke2-subnet --no-address \
3  --machine-type=e2-micro --image-family=debian-13 --image-project=debian-cloud
4
5# let the console SSH into the no-public-IP VM via IAP's fixed range
6gcloud compute firewall-rules create spoke2-fw-allow-iap-ssh --project=cl-demo-sandboxcli \
7  --network=spoke2-vpc --direction=INGRESS --action=ALLOW \
8  --rules=tcp:22 --source-ranges=35.235.240.0/20

4 · Peer across the projects — one side in each

The magic is --peer-project: each side names the other project. It goes ACTIVE only after both halves exist.

 1# hub side — runs in project A, points at project B
 2gcloud compute networks peerings create hub-to-spoke2 --project=cl-demo-sandbox \
 3  --network=hub-vpc --peer-network=spoke2-vpc --peer-project=cl-demo-sandboxcli
 4
 5# spoke2 side — runs in project B, points at project A
 6gcloud compute networks peerings create spoke2-to-hub --project=cl-demo-sandboxcli \
 7  --network=spoke2-vpc --peer-network=hub-vpc --peer-project=cl-demo-sandbox
 8
 9# both must say ACTIVE
10gcloud compute networks peerings list --network=hub-vpc --project=cl-demo-sandbox

5 · Firewall — make the exchanged routes usable

Routes now cross the project boundary; the firewall on each side still has to allow the traffic.

1# project A: widen the hub rule to also allow spoke2's range
2gcloud compute firewall-rules update hub-fw-allow-from-spokes --project=cl-demo-sandbox \
3  --source-ranges=10.20.1.0/24,10.30.1.0/24
4
5# project B: let the hub's range in
6gcloud compute firewall-rules create spoke2-fw-allow-from-hub --project=cl-demo-sandboxcli \
7  --network=spoke2-vpc --direction=INGRESS --action=ALLOW \
8  --rules=icmp,tcp:22 --source-ranges=10.10.1.0/24

6 · Test — and the transitive trap

SSH into each VM from the console and ping by internal IP (names don't resolve across peering):

1# from hub-vm  → spoke2-vm : SUCCESS — hub peers with spoke2, across projects 🎉
2ping -c 3 10.30.1.2
3
4# from spoke1-vm → spoke2-vm : FAILS — even though both peer with the hub
5ping -c 3 10.30.1.2

The trap: peering is never transitive, and no firewall rule can fix it — there's simply no route from spoke1 to spoke2. The real-world fix is Network Connectivity Center or a proxy in the hub.



5. Terraform - the two-provider pattern

The trick for anything cross-project in Terraform is two providers - one per project - using a provider alias. Every resource which belongs to project B carries provider = google.workload. The hub already exists (managed by the Terraform from the previous post), so here we focus on project B's resources and the two peering directions.

If you have never used provider aliases before, I have explained the same pattern for AWS in my post on Terraform with multiple AWS accounts - the idea is identical.

Cross-project as code. The trick is two providers — one per project — using a provider alias. The hub already exists (managed by the VPC Peering config), so here we focus on project B's resources and the two peering directions.

providers.tf — one provider per project

 1# project A (default) — where hub-vpc lives
 2provider "google" {
 3  project = "cl-demo-sandbox"
 4  region  = "europe-north2"
 5}
 6
 7# project B — the workload project, addressed as google.workload
 8provider "google" {
 9  alias   = "workload"
10  project = "cl-demo-sandboxcli"
11  region  = "europe-north2"
12}

workload.tf — spoke2 network, subnet, VM (project B)

Every resource in project B carries provider = google.workload.

 1resource "google_compute_network" "spoke2" {
 2  provider                = google.workload
 3  name                    = "spoke2-vpc"
 4  auto_create_subnetworks = false
 5}
 6
 7resource "google_compute_subnetwork" "spoke2" {
 8  provider      = google.workload
 9  name          = "spoke2-subnet"
10  network       = google_compute_network.spoke2.id
11  region        = "europe-north2"
12  ip_cidr_range = "10.30.1.0/24"
13}
14
15resource "google_compute_instance" "spoke2" {
16  provider     = google.workload
17  name         = "spoke2-vm"
18  zone         = "europe-north2-a"
19  machine_type = "e2-micro"
20  boot_disk {
21    initialize_params { image = "debian-cloud/debian-13" }
22  }
23  network_interface {
24    subnetwork = google_compute_subnetwork.spoke2.id
25    # no access_config → no external IP
26  }
27}

peering.tf — one resource per side, each in its own project

 1# hub side — default provider (project A)
 2resource "google_compute_network_peering" "hub_to_spoke2" {
 3  name         = "hub-to-spoke2"
 4  network      = "projects/cl-demo-sandbox/global/networks/hub-vpc"
 5  peer_network = google_compute_network.spoke2.self_link
 6}
 7
 8# spoke2 side — workload provider (project B)
 9resource "google_compute_network_peering" "spoke2_to_hub" {
10  provider     = google.workload
11  name         = "spoke2-to-hub"
12  network      = google_compute_network.spoke2.self_link
13  peer_network = "projects/cl-demo-sandbox/global/networks/hub-vpc"
14}

firewall.tf

 1# project B: let the hub's range reach spoke2's VMs
 2resource "google_compute_firewall" "spoke2_fw_allow_from_hub" {
 3  provider      = google.workload
 4  name          = "spoke2-fw-allow-from-hub"
 5  network       = google_compute_network.spoke2.id
 6  direction     = "INGRESS"
 7  source_ranges = ["10.10.1.0/24"]
 8  allow { protocol = "icmp" }
 9  allow {
10    protocol = "tcp"
11    ports    = ["22"]
12  }
13}
14
15# project B: IAP SSH into the no-public-IP VM
16resource "google_compute_firewall" "spoke2_fw_allow_iap_ssh" {
17  provider      = google.workload
18  name          = "spoke2-fw-allow-iap-ssh"
19  network       = google_compute_network.spoke2.id
20  direction     = "INGRESS"
21  source_ranges = ["35.235.240.0/20"]
22  allow {
23    protocol = "tcp"
24    ports    = ["22"]
25  }
26}

Note: the hub's hub-fw-allow-from-spokes rule (in project A) must include 10.30.1.0/24 in its source_ranges — update it where that rule is defined rather than duplicating it here.

Tip - Notice that the hub side references "projects/cl-demo-sandbox/global/networks/hub-vpc" as a plain string instead of a resource reference. That is on purpose - the hub is in another Terraform state. If both projects are in one state, use google_compute_network.hub.self_link instead, or read it with a terraform_remote_state data source.


6. The transitive trap across projects and the real fix

After step 6 you have hub ↔ spoke1 (same project) and hub ↔ spoke2 (cross-project). SSH into each VM from the console and ping by internal IP (names do not resolve across peering) -

1# from hub-vm    -> spoke2-vm : SUCCESS, hub peers with spoke2, across projects
2ping -c 3 10.30.1.2
3
4# from spoke1-vm -> spoke2-vm : FAILS, even though both peer with the hub
5ping -c 3 10.30.1.2

The trap - peering is never transitive, and no firewall rule can fix it. There is simply no route from spoke1 to spoke2. Across projects this is the moment where somebody says "but the network team said we are connected to the hub".

The real-world fixes, in the order I would try them -

  1. Shared VPC - If all the projects are in one Organization, put the workloads on one network owned by the hub project. There is nothing to peer, so nothing is transitive. This is the enterprise answer - Google Cloud Shared VPC.
  2. Network Connectivity Center - Attach each spoke VPC as a spoke of an NCC hub. NCC does route between spokes.
  3. Private Service Connect - If spoke1 only needs to reach one service in spoke2 (not the whole network), publish that service with Private Service Connect instead of connecting networks at all.
  4. A proxy VM in the hub - The old-school way. Works, but now you own a bottleneck.

7. Common errors

1. The resource 'projects/.../global/networks/spoke2-vpc' was not found - You ran the peering command in the wrong project, or forgot --peer-project. Each command has a --project (where this side lives) and a --peer-project (where the other network lives).

2. The peering stays INACTIVE - The other side has not been created yet, or the IP ranges overlap. Check gcloud compute networks peerings list --network=hub-vpc on both projects.

3. Required 'compute.networks.addPeering' permission - You need Compute Network Admin (or Owner) on the project where you are creating your side. You do not need anything on the other project.

4. Ping works hub → spoke2 but not spoke2 → hub - The hub's firewall rule does not include spoke2's range. Update hub-fw-allow-from-spokes with --source-ranges=10.20.1.0/24,10.30.1.0/24 as in step 5.

5. Billing account for project is not found when creating the VM - Project B has no billing. gcloud billing projects link first (step 1).


Cross-project VPC peering is how you connect a central hub to application projects owned by other teams, without giving anybody access to anybody else's network. The commands are the same as same-project peering plus --peer-project, and in Terraform it is a second provider with an alias.

Just remember - it is still not transitive. When the number of spokes grows, or spokes need to talk to each other, move to Shared VPC, which first needs a Google Cloud Organization.

The Google Cloud networking series -

  1. Google Cloud VPC peering - hub and spoke
  2. Google Cloud cross-project VPC peering (this post)
  3. Google Cloud Organization setup with Cloud Identity (free)
  4. Google Cloud Shared VPC - one network, many projects
  5. Google Cloud Private Service Connect - publish one service privately

Posts in this series