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
- What is cross-project VPC peering and how is it different?
- What we are going to build
- Build it step by step (diagrams, gcloud and Terraform for every step)
- gcloud CLI walkthrough - the complete command list
- Terraform - the two-provider pattern
- The transitive trap across projects and the real fix
- Common errors
- 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 -
- Each side is created in its own project, with
--peer-projectpointing at the other project. You run one command in project A and one command in project B. - 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.
- 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-sandboxwith 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-sandboxcliwith spoke2-vpc (10.30.1.0/24) and spoke2-vm (no public IP, IAP SSH) - A peering between
hub-vpcandspoke2-vpcacross 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
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.
# 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# 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"
}gcloud compute networks create spoke2-vpc --subnet-mode=custom --project=cl-demo-sandboxcli
gcloud compute networks subnets create spoke2-subnet --project=cl-demo-sandboxcli \
--network=spoke2-vpc --region=europe-north2 --range=10.30.1.0/24resource "google_compute_network" "spoke2" {
provider = google.workload
name = "spoke2-vpc"
auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "spoke2" {
provider = google.workload
name = "spoke2-subnet"
network = google_compute_network.spoke2.id
region = "europe-north2"
ip_cidr_range = "10.30.1.0/24"
}gcloud compute instances create spoke2-vm --project=cl-demo-sandboxcli \
--zone=europe-north2-a --subnet=spoke2-subnet --no-address \
--machine-type=e2-micro --image-family=debian-13 --image-project=debian-cloud
gcloud compute firewall-rules create spoke2-fw-allow-iap-ssh --project=cl-demo-sandboxcli \
--network=spoke2-vpc --direction=INGRESS --action=ALLOW \
--rules=tcp:22 --source-ranges=35.235.240.0/20resource "google_compute_instance" "spoke2" {
provider = google.workload
name = "spoke2-vm"
zone = "europe-north2-a"
machine_type = "e2-micro"
boot_disk {
initialize_params { image = "debian-cloud/debian-13" }
}
network_interface {
subnetwork = google_compute_subnetwork.spoke2.id
}
}
resource "google_compute_firewall" "spoke2_fw_allow_iap_ssh" {
provider = google.workload
name = "spoke2-fw-allow-iap-ssh"
network = google_compute_network.spoke2.id
direction = "INGRESS"
source_ranges = ["35.235.240.0/20"]
allow {
protocol = "tcp"
ports = ["22"]
}
}# one side in EACH project — --peer-project names the other
gcloud compute networks peerings create hub-to-spoke2 --project=cl-demo-sandbox \
--network=hub-vpc --peer-network=spoke2-vpc --peer-project=cl-demo-sandboxcli
gcloud compute networks peerings create spoke2-to-hub --project=cl-demo-sandboxcli \
--network=spoke2-vpc --peer-network=hub-vpc --peer-project=cl-demo-sandbox# hub side — default provider (project A)
resource "google_compute_network_peering" "hub_to_spoke2" {
name = "hub-to-spoke2"
network = "projects/cl-demo-sandbox/global/networks/hub-vpc"
peer_network = google_compute_network.spoke2.self_link
}
# spoke2 side — workload provider (project B)
resource "google_compute_network_peering" "spoke2_to_hub" {
provider = google.workload
name = "spoke2-to-hub"
network = google_compute_network.spoke2.self_link
peer_network = "projects/cl-demo-sandbox/global/networks/hub-vpc"
}# project A: widen the hub rule to also allow spoke2's range
gcloud compute firewall-rules update hub-fw-allow-from-spokes --project=cl-demo-sandbox \
--source-ranges=10.20.1.0/24,10.30.1.0/24
# project B: let the hub's range in
gcloud compute firewall-rules create spoke2-fw-allow-from-hub --project=cl-demo-sandboxcli \
--network=spoke2-vpc --direction=INGRESS --action=ALLOW \
--rules=icmp,tcp:22 --source-ranges=10.10.1.0/24resource "google_compute_firewall" "spoke2_fw_allow_from_hub" {
provider = google.workload
name = "spoke2-fw-allow-from-hub"
network = google_compute_network.spoke2.id
direction = "INGRESS"
source_ranges = ["10.10.1.0/24"]
allow { protocol = "icmp" }
allow {
protocol = "tcp"
ports = ["22"]
}
}
# + add "10.30.1.0/24" to hub-fw-allow-from-spokes where that rule is defined# hub-vm → spoke2-vm : SUCCESS (cross-project) 🎉
ping -c 3 10.30.1.2
# spoke1-vm → spoke2-vm : FAILS — peering is never transitive
ping -c 3 10.30.1.24. 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, usegoogle_compute_network.hub.self_linkinstead, 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 -
- 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.
- Network Connectivity Center - Attach each spoke VPC as a spoke of an NCC hub. NCC does route between spokes.
- 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.
- 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).
8. Conclusion and what to read next
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 -
Posts in this series
- Google Cloud Private Service Connect (PSC) Explained - Publish a Service with a Service Attachment and Consume It Through a PSC Endpoint
- Google Cloud Shared VPC Explained - Host Project, Service Project, networkUser and Cloud NAT (gcloud and Terraform)
- Google Cloud Organization Setup with Cloud Identity Free - Create an Org and Move Your Projects Under It
- Google Cloud Cross-Project VPC Peering - Peer Two VPCs in Different Projects (gcloud and Terraform)
- Google Cloud VPC Peering - Hub and Spoke Setup Step by Step with gcloud and Terraform