Google Cloud Shared VPC Explained - Host Project, Service Project, networkUser and Cloud NAT (gcloud and Terraform)


In the last two posts we connected networks with VPC peering and cross-project VPC peering, and both times we ran into the same wall - peering is not transitive, and every team still has to build and maintain its own VPC. In this blog post we are going to look at the way enterprises actually solve this on Google Cloud - Shared VPC.

With Shared VPC there is only one network. One project (the host) owns it, and other projects (the service projects) simply borrow its subnets. The network team runs the network, the app teams run workloads on it, and nobody peers anything. We will build it end to end - host, service project, IAM, firewall, a VM in the service project sitting on the host's subnet - and then one Cloud NAT so that every workload in every service project goes out through a single egress IP.

Note - Shared VPC needs a Google Cloud Organization, it does not work on standalone projects. If you do not have one yet, do my Google Cloud Organization setup with Cloud Identity post first - it is free and takes about half an hour.

Table of Content

  1. What is Shared VPC?
  2. Shared VPC vs VPC peering - before and after
  3. The five benefits of Shared VPC
  4. Who controls what - host project vs service project
  5. When to use Shared VPC and when not to
  6. What we are going to build
  7. Build it step by step (diagrams, gcloud and Terraform for every step)
  8. Three demos that show the real value
  9. gcloud CLI walkthrough - the complete command list
  10. The same Shared VPC in Terraform
  11. Common errors
  12. Conclusion and what to read next


1. What is Shared VPC?

Shared VPC lets one project own a network and lend its subnets to other projects in the same organization. One team runs the network, other teams run workloads on it, without ever creating a VPC of their own and without peering anything.

If I had to put the benefit in one sentence - the app team gets a machine on a fully governed network, without owning it, building it, or being able to misconfigure it. That is Shared VPC in a nutshell.

It is the opposite trade-off from VPC peering. Peering joins two networks that both exist. Shared VPC means there is only one network, and other projects attach to it. No peering, no route exchange, no transitive limits - because there is nothing to traverse.

The two roles you need to know -

  • Host project - Owns the VPC, the subnets, the firewall rules, the routes, Cloud NAT, VPN. Usually run by the network or platform team.
  • Service project - Owns the workloads (VMs, GKE clusters, Cloud SQL) but has no VPC of its own. Its resources get their network interface from a subnet in the host project.


2. Shared VPC vs VPC peering - before and after

Here is what the same three teams look like with peering versus with Shared VPC. On the left every team has its own VPC and the hub has to peer with each one. On the right there is one network and everybody is on it.

🔍 Click to zoom WHY IT EXISTSWithout Shared VPC every team rebuilds the same networkShared VPC builds the network once and lets every project plug inWITHOUT SHARED VPCapp projectbuilds its OWN vpc · subnet · firewall · nat · vpndata projectbuilds its OWN vpc · subnet · firewall · nat · vpnweb projectbuilds its OWN vpc · subnet · firewall · nat · vpnSame network, built & audited 3× overduplicated effort · drifting configs · no single place to enforce rulesWITH SHARED VPCnetwork team · host projectONE vpc · subnet · firewall · nat · vpn — built once, centrallyappjust a VMdatajust a VMwebjust a VMBuild once — every app team just plugs incentral control · zero duplication · one rule set to audit
Before - every project has its own VPC and peers with the hub. After - one Shared VPC, service projects attach to it. Click to zoom

3. The five benefits of Shared VPC

🔍 Click to zoom THE PAYOFFEverything good flows from one shared networkThe five wins that make enterprises standardise on Shared VPCShared VPC🛡️Separation of dutiesnetwork team owns the VPC, subnets & firewall — app teams just consume♻️No duplicationone VPC, NAT, VPN & firewall policy shared across many projects🔗Free private connectivityservice projects talk over internal IPs — no peering, no transitive limits⚖️Autonomy + governanceown project, IAM & billing per team; the network stays central🔒Security in one placeone firewall rule set to audit and enforce — not one per project
The five benefits of Shared VPC - click to zoom
  1. Separation of duties - The network team owns the VPC, subnets, firewall and routing. App teams just consume. Developers cannot create rogue networks or open firewall holes.
  2. No duplication - One VPC, one Cloud NAT, one VPN or Interconnect, one firewall policy, shared across many projects. Instead of every team rebuilding (and drifting) the same stack.
  3. Free private connectivity - Every service project talks over internal IPs with no peering and no transitive limits, because they are all on the same network.
  4. Team autonomy plus central governance - Each team keeps its own project, IAM, billing and quotas, but the network stays centrally controlled. Autonomy where it is safe, control where it matters.
  5. Security and compliance in one place - One firewall rule set to audit and enforce, not one per project. Auditors look at the host project, full stop.

The rule that surprises everyone - firewall rules are host-project resources. You will go looking for them in the service project and find nothing. Every rule for every service project lives in the host.



4. Who controls what - host project vs service project

🔍 Click to zoom SEPARATION OF DUTIESWho controls whatOne team owns the network · every other team just runs on it📁 SHARED VPC · host project (jhooq-prod-net-host-01)shared-vpc · shared-subnet 10.50.1.0/24the one network everyone runs onfirewall · routes · subnetscreated and managed only by the platform teamapp-vm-01 · 10.50.1.2← app team's workloaddata-vm-01 · 10.50.1.3← app team's workloadPlatform / Network Teamowns the shared network🔑 full controlApp Team A · appruns workloads only🔒 no network accessApp Team B · dataruns workloads only🔒 no network accesscreates & manages the networkruns its VMruns its VM🔒 platform-only
What the host project controls and what the service project controls - click to zoom

The IAM roles which make this work -

RoleGranted onWho gets itWhat it allows
roles/compute.xpnAdmin (Shared VPC Admin)The organization (or a folder)The network teamEnable a host project and attach service projects
roles/compute.networkUserA subnet in the host project (least privilege) or the whole host projectApp team members and the service project's service accountsCreate resources that use that subnet
roles/compute.instanceAdmin (or Owner)The service projectApp teamCreate the VMs themselves

The one most people miss is networkUser. Attaching a service project is not enough - the people and service accounts creating VMs must also be allowed to use the subnet. We grant it in step 5.


5. When to use Shared VPC and when not to

Use Shared VPC when -

  • Multiple teams or projects need to sit on one governed network (the classic enterprise landing zone).
  • You want central control of firewall, routing, NAT and connectivity, with teams still isolated in their own projects.
  • Workloads must talk privately across projects without the overhead (and transitive limits) of peering.

Reach for something else when -

  • It is a single project with no sharing need - just a normal VPC.
  • Two independent networks (or two organizations) need to interconnect - that is VPC peering.
  • You need transitive hub-and-spoke routing across many VPCs which must stay separate - look at Network Connectivity Center.
  • You want to expose one service to another team without sharing a network at all - that is Private Service Connect.


6. What we are going to build

  • Organization 669440902976 (replace with yours)
  • Host project jhooq-prod-net-host-01 with shared-vpc and shared-subnet (10.50.1.0/24) in europe-north2
  • Service project jhooq-prod-app-01, attached to the host
  • app-vm-01 in the service project, no public IP, with its NIC in the host's subnet, reachable over IAP SSH
  • One Cloud Router and Cloud NAT in the host with a reserved egress IP, so every service-project VM egresses through the same address
🔍 Click to zoom 📁 HOST PROJECTSHARED VPC HOST ✓shared-vpcVPC NETWORKshared-subneteurope-north2 · 10.50.1.0/24firewall rules live here, in the host📁 SERVICE PROJECT · appapp-vm-01 · 10.50.1.2no VPC of its own📁 SERVICE PROJECT · datadata-vm-01 · 10.50.1.3no VPC of its ownNICNICone network in the host · many service projects borrow its subnets
The host project lends its subnet to the service project - click to zoom

Prerequisites - both projects under the same organization (set one up here), billing on both, and gcloud logged in as a user who is Owner of both projects. For Terraform, install Terraform and configure the Google provider.


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

Ten steps. Steps 1 to 7 are the core Shared VPC, steps 8 to 10 are the Cloud NAT demo. Click a step in the rail, or use Prev / Next or the arrow keys. Click the diagram to zoom.

🔍 Click to zoomSTEP 1 / 10Shared VPC Adminroles/compute.xpnAdmin — granted at the ORGANIZATION level🏢 ORGANIZATION · jhooq.comShared VPC Admin · roles/compute.xpnAdmingranted on the organization — not on a projectNEW📂 FOLDER · production📁 PROJECT · jhooq-prod-net-host-01📁 PROJECT · jhooq-prod-app-01
▶ CLI command for this step
# roles/compute.xpnAdmin is granted on the ORGANIZATION, never on a project
gcloud organizations add-iam-policy-binding "$ORG_ID" \
  --member="user:rahul.wagh.gcloud@jhooq.com" \
  --role="roles/compute.xpnAdmin"
✦ Terraform for this step
# usually granted once by hand — it's the permission to RUN this code
resource "google_organization_iam_member" "xpn_admin" {
  org_id = var.org_id
  role   = "roles/compute.xpnAdmin"
  member = var.admin_user
}
Step 1 of 10


8. Three demos that show the real value

Provisioning a single VM is Act 1 - it proves the plumbing works. The value only lands when you see what one network shared across projects actually buys you. Here are the three demos I show in the video.

🔍 Click to zoom WHAT WE PROVEThree demonstrations that make the value visibleThe single VM is Act 1 — these show why it matters🌐 Internet📁 HOST · jhooq-prod-net-host-01shared-vpc · shared-subnet 10.50.1.0/24one network for every service projectfirewall rulescentral, host-ownedCloud NATone shared egress IP📁 SERVICE · appapp-vm-01 · 10.50.1.2no VPC of its own — borrows the host subnet📁 SERVICE · datadata-vm-01 · 10.50.1.3no VPC of its own — borrows the host subnet1private ping over internal IP — no peering, no transitive limit2one host rule→ governs every VM✕ app team can't create a firewall — PERMISSION DENIED3curl ifconfig.me → same IP from both VMs
The three demos - private ping between service projects, central governance, one shared egress IP. Click to zoom

Provisioning a single VM is Act 1 — it proves the plumbing works, but the value only lands when you show what one network shared across projects actually buys you. Here are three demonstrations, each with the one line to say on camera.

Demo A — Two service projects, one private ping 🎯

Attach a second service project (jhooq-prod-data-01), drop a VM in it on the same shared subnet, and ping app-vm-01 ↔ data-vm-01 over their internal IPs. It just works — instantly, with zero networking between the projects.

"Remember cross-project peering and the transitive trap? Here there's nothing to peer — it's one network. Every service project is already on it."

This is the strongest single demo: it makes "one network, many projects" tangible.

Demo B — Centralised governance (separation of duties, made real) 🎯

Two halves, both from the host project:

  1. Change one firewall rule in the host → it instantly governs VMs across every service project.
  2. Then try to create a firewall rule from the app project → PERMISSION DENIED.

"App teams can't poke holes in the network — only the network team can. One rule set, one place to audit."

This is the "why enterprises love it" moment — autonomy for teams, control for the platform.

Demo C — One shared egress IP with Cloud NAT ⭐

Create one Cloud Router + Cloud NAT in the host. Every service-project VM (all with no external IP) now reaches the internet through a single egress address. Run curl ifconfig.me from both VMs — same IP.

"One NAT for every workload in every service project — and a single egress IP you can hand to a partner to allow-list."

It's the clearest picture of centralised, cost-efficient networking at scale — and it's fully built in steps 8–10 below (Cloud Router → Cloud NAT + reserved IP → verify egress), with the storyboard just below this section.


The map above numbers these ①②③ so you can point at each while you narrate. All three demos are documented end-to-end: Demo C (Cloud NAT) is the storyboard in the next section and steps 8–10 of the CLI and Terraform; Demos A & B are described here with the commands in the CLI section.

🔍 Click to zoom CLOUD NAT EGRESS ⭐One shared egress IP for every workloadNo public IPs anywhere — every service VM leaves through the host's Cloud NAT🌐 Internet📁 HOST · shared-vpcshared-natone Cloud NAT for every VM🌐 reserved egress IP · 34.88.20.15app-vm-01app project🚫 no public IPdata-vm-01data project🚫 no public IPweb-vm-01web project🚫 no public IPone shared egress IPcurl ifconfig.me → 34.88.20.15— same from every VM🎯 Allowlist ONE IP for every workloadno per-project NAT · no public IPs to manage · one egress to secure
Demo C - one Cloud NAT in the host, one egress IP for every service project. Click to zoom

9. gcloud CLI walkthrough - the complete command list

Build the Shared VPC end to end - one host project lends its subnet to one service project. Run these in order, every command is copy-paste ready.

Build a Shared VPC end to end: one host project lends its subnet to one service project. Run these in order — every command is copy-paste ready.

0 · Set your variables

Everything below reuses these. Both projects must already sit under the same organization (Shared VPC cannot work across orgs, and never on standalone projects).

1export ORG_ID=669440902976
2export HOST_PROJECT=jhooq-prod-net-host-01
3export SVC_PROJECT=jhooq-prod-app-01
4export REGION=europe-north2
5export ZONE=europe-north2-a

Confirm both projects really are under the org — this is the #1 cause of a failed attach:

1gcloud projects get-ancestors "$HOST_PROJECT"
2gcloud projects get-ancestors "$SVC_PROJECT"

Enable the APIs in both projects:

1gcloud services enable compute.googleapis.com iap.googleapis.com --project="$HOST_PROJECT"
2gcloud services enable compute.googleapis.com iap.googleapis.com --project="$SVC_PROJECT"

1 · Become a Shared VPC Admin

roles/compute.xpnAdmin is granted on the organization, not on a project. Without it, step 2 fails with a permissions error.

1gcloud organizations add-iam-policy-binding "$ORG_ID" \
2  --member="user:rahul.wagh.gcloud@jhooq.com" \
3  --role="roles/compute.xpnAdmin"

2 · Enable the host project

1gcloud compute shared-vpc enable "$HOST_PROJECT"

Verify it took:

1gcloud compute shared-vpc get-host-project "$HOST_PROJECT"

3 · Create the shared network and subnet

The network and subnet live in the host project. --enable-private-ip-google-access lets a VM with no external IP still reach Google APIs (needed for apt, logging, and IAP).

 1gcloud compute networks create shared-vpc \
 2  --project="$HOST_PROJECT" \
 3  --subnet-mode=custom
 4
 5gcloud compute networks subnets create shared-subnet \
 6  --project="$HOST_PROJECT" \
 7  --network=shared-vpc \
 8  --region="$REGION" \
 9  --range=10.50.1.0/24 \
10  --enable-private-ip-google-access

4 · Attach the service project

1gcloud compute shared-vpc associated-projects add "$SVC_PROJECT" \
2  --host-project="$HOST_PROJECT"

Confirm the attachment from both directions:

1gcloud compute shared-vpc list-associated-resources "$HOST_PROJECT"
2gcloud compute shared-vpc get-host-project "$SVC_PROJECT"

5 · Grant subnet access (networkUser)

Attaching alone is not enough — principals still need roles/compute.networkUser to actually use the subnet. Grant it on the subnet, not the whole host project: that's the least-privilege form, and it's what keeps one service project out of another's subnets.

Grant it to the human who creates VMs and to the service project's default compute service account (needed for anything Google creates on your behalf — MIGs, GKE, Dataflow):

 1export SVC_PROJECT_NUMBER=$(gcloud projects describe "$SVC_PROJECT" --format="value(projectNumber)")
 2
 3# the user who will create instances
 4gcloud compute networks subnets add-iam-policy-binding shared-subnet \
 5  --project="$HOST_PROJECT" --region="$REGION" \
 6  --member="user:rahul.wagh.gcloud@jhooq.com" \
 7  --role="roles/compute.networkUser"
 8
 9# the service project's default compute service account
10gcloud compute networks subnets add-iam-policy-binding shared-subnet \
11  --project="$HOST_PROJECT" --region="$REGION" \
12  --member="serviceAccount:${SVC_PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \
13  --role="roles/compute.networkUser"

6 · Firewall rules — in the HOST project

This is the one that catches everyone: firewall rules are host-project resources. You will not find them in the service project. One rule set protects every service project on the network.

 1# IAP SSH — 35.235.240.0/20 is Google's IAP forwarding range
 2gcloud compute firewall-rules create shared-vpc-fw-allow-iap-ssh \
 3  --project="$HOST_PROJECT" \
 4  --network=shared-vpc \
 5  --direction=INGRESS --action=ALLOW \
 6  --rules=tcp:22 \
 7  --source-ranges=35.235.240.0/20
 8
 9# east-west traffic inside the subnet
10gcloud compute firewall-rules create shared-vpc-fw-allow-internal \
11  --project="$HOST_PROJECT" \
12  --network=shared-vpc \
13  --direction=INGRESS --action=ALLOW \
14  --rules=icmp,tcp:22 \
15  --source-ranges=10.50.1.0/24

7 · Create a VM in the service project

The VM belongs to the service project, but its --subnet is a fully-qualified path into the host project. That one flag is the whole trick.

1gcloud compute instances create app-vm-01 \
2  --project="$SVC_PROJECT" \
3  --zone="$ZONE" \
4  --machine-type=e2-micro \
5  --image-family=debian-13 --image-project=debian-cloud \
6  --no-address \
7  --subnet="projects/${HOST_PROJECT}/regions/${REGION}/subnetworks/shared-subnet"

Verify

 1# the NIC points at the HOST project's subnet
 2gcloud compute instances describe app-vm-01 \
 3  --project="$SVC_PROJECT" --zone="$ZONE" \
 4  --format="value(networkInterfaces[0].subnetwork)"
 5
 6# no VPC was ever created in the service project
 7gcloud compute networks list --project="$SVC_PROJECT"
 8
 9# SSH in over IAP (no public IP anywhere)
10gcloud compute ssh app-vm-01 \
11  --project="$SVC_PROJECT" --zone="$ZONE" --tunnel-through-iap

The subnetwork value should read .../projects/jhooq-prod-net-host-01/regions/europe-north2/subnetworks/shared-subnet, and networks list on the service project should come back empty — proof the workload runs on a network it doesn't own.


Demo C — Cloud NAT egress (one shared IP for every workload)

Your VM has no public IP, so it can't reach the internet yet. Instead of giving each VM a public address (or a NAT per project), you put one Cloud NAT in the host — and every no-public-IP VM, in every service project on this network, egresses through one shared IP you can hand to a partner to allow-list.

8 · Cloud Router (in the host)

Cloud NAT rides on a Cloud Router. Create it in the host, on the shared VPC, in the region:

1gcloud compute routers create shared-nat-router \
2  --project="$HOST_PROJECT" \
3  --network=shared-vpc \
4  --region="$REGION"

9 · Cloud NAT + a reserved egress IP

Reserve a static regional address so the egress IP is fixed (allow-listable), then create the NAT on the router, translating the shared subnet:

1# a fixed, allow-listable egress address
2gcloud compute addresses create shared-nat-ip \
3  --project="$HOST_PROJECT" --region="$REGION"
4
5gcloud compute routers nats create shared-nat \
6  --project="$HOST_PROJECT" \
7  --router=shared-nat-router --region="$REGION" \
8  --nat-custom-subnet-ip-ranges=shared-subnet \
9  --nat-external-ip-pool=shared-nat-ip

10 · Verify shared egress

SSH into the service VM over IAP and check its egress address — it's the NAT's reserved IP:

1gcloud compute ssh app-vm-01 \
2  --project="$SVC_PROJECT" --zone="$ZONE" --tunnel-through-iap \
3  --command="curl -s ifconfig.me; echo"
4# → 34.88.20.15   (the reserved shared-nat-ip)
5
6# the reserved IP itself, for your allow-lists
7gcloud compute addresses describe shared-nat-ip \
8  --project="$HOST_PROJECT" --region="$REGION" --format="value(address)"

Spin up a VM in a second service project on the same subnet and curl ifconfig.me returns the same address. One NAT, one egress IP, every workload — that's the enterprise win.

Tear it down

Reverse order — NAT and router first, detach before you can disable the host.

 1# Cloud NAT egress
 2gcloud compute routers nats delete shared-nat \
 3  --router=shared-nat-router --project="$HOST_PROJECT" --region="$REGION" --quiet
 4gcloud compute routers delete shared-nat-router --project="$HOST_PROJECT" --region="$REGION" --quiet
 5gcloud compute addresses delete shared-nat-ip --project="$HOST_PROJECT" --region="$REGION" --quiet
 6
 7# core Shared VPC
 8gcloud compute instances delete app-vm-01 --project="$SVC_PROJECT" --zone="$ZONE" --quiet
 9gcloud compute firewall-rules delete shared-vpc-fw-allow-internal shared-vpc-fw-allow-iap-ssh \
10  --project="$HOST_PROJECT" --quiet
11gcloud compute shared-vpc associated-projects remove "$SVC_PROJECT" --host-project="$HOST_PROJECT"
12gcloud compute networks subnets delete shared-subnet --project="$HOST_PROJECT" --region="$REGION" --quiet
13gcloud compute networks delete shared-vpc --project="$HOST_PROJECT" --quiet
14gcloud compute shared-vpc disable "$HOST_PROJECT"

10. The same Shared VPC in Terraform

The same Shared VPC in Terraform. Two things differ from the CLI flow - the host and service wiring uses the dedicated google_compute_shared_vpc_host_project and google_compute_shared_vpc_service_project resources, and the org-level xpnAdmin grant is usually done once by hand, because it is a permission to run this code, not part of it.

Unlike cross-project peering, you do not need a second provider alias here - every resource is addressed with an explicit project argument. I have used Terraform variables for the project IDs so you can drop in your own in a terraform.tfvars.

The same Shared VPC in Terraform. Two things differ from the CLI flow: the host/service wiring uses dedicated google_compute_shared_vpc_* resources, and the org-level admin grant is usually done once by hand (it's a permission to run this code, not part of it).

providers.tf

Unlike cross-project peering, you do not need a second provider alias — every resource is addressed with an explicit project argument.

 1terraform {
 2  required_providers {
 3    google = {
 4      source  = "hashicorp/google"
 5      version = "~> 6.0"
 6    }
 7  }
 8}
 9
10provider "google" {
11  region = var.region
12}

variables.tf

 1variable "org_id" {
 2  type    = string
 3  default = "669440902976"
 4}
 5
 6variable "host_project" {
 7  type    = string
 8  default = "jhooq-prod-net-host-01"
 9}
10
11variable "service_project" {
12  type    = string
13  default = "jhooq-prod-app-01"
14}
15
16variable "region" {
17  type    = string
18  default = "europe-north2"
19}
20
21variable "zone" {
22  type    = string
23  default = "europe-north2-a"
24}
25
26variable "subnet_cidr" {
27  type    = string
28  default = "10.50.1.0/24"
29}
30
31variable "admin_user" {
32  type    = string
33  default = "user:rahul.wagh.gcloud@jhooq.com"
34}

shared-vpc.tf

Host + service wiring

google_compute_shared_vpc_host_project is the Terraform equivalent of shared-vpc enable; google_compute_shared_vpc_service_project is the attach.

1resource "google_compute_shared_vpc_host_project" "host" {
2  project = var.host_project
3}
4
5resource "google_compute_shared_vpc_service_project" "app" {
6  host_project    = google_compute_shared_vpc_host_project.host.project
7  service_project = var.service_project
8}

The network and subnet (host project)

 1resource "google_compute_network" "shared" {
 2  project                 = var.host_project
 3  name                    = "shared-vpc"
 4  auto_create_subnetworks = false
 5}
 6
 7resource "google_compute_subnetwork" "shared" {
 8  project                  = var.host_project
 9  name                     = "shared-subnet"
10  network                  = google_compute_network.shared.id
11  region                   = var.region
12  ip_cidr_range            = var.subnet_cidr
13  private_ip_google_access = true
14}

Subnet-level networkUser — least privilege

Granting on the subnet (not the host project) is what keeps one service project out of another's subnets. google_compute_subnetwork_iam_member is the subnet-scoped binding.

 1data "google_project" "service" {
 2  project_id = var.service_project
 3}
 4
 5locals {
 6  network_users = [
 7    var.admin_user,
 8    "serviceAccount:${data.google_project.service.number}-compute@developer.gserviceaccount.com",
 9  ]
10}
11
12resource "google_compute_subnetwork_iam_member" "network_user" {
13  for_each = toset(local.network_users)
14
15  project    = var.host_project
16  region     = var.region
17  subnetwork = google_compute_subnetwork.shared.name
18  role       = "roles/compute.networkUser"
19  member     = each.value
20}

Firewall — always in the host project

 1resource "google_compute_firewall" "allow_iap_ssh" {
 2  project       = var.host_project
 3  name          = "shared-vpc-fw-allow-iap-ssh"
 4  network       = google_compute_network.shared.id
 5  direction     = "INGRESS"
 6  source_ranges = ["35.235.240.0/20"] # Google's IAP forwarding range
 7
 8  allow {
 9    protocol = "tcp"
10    ports    = ["22"]
11  }
12}
13
14resource "google_compute_firewall" "allow_internal" {
15  project       = var.host_project
16  name          = "shared-vpc-fw-allow-internal"
17  network       = google_compute_network.shared.id
18  direction     = "INGRESS"
19  source_ranges = [var.subnet_cidr]
20
21  allow { protocol = "icmp" }
22  allow {
23    protocol = "tcp"
24    ports    = ["22"]
25  }
26}

The VM — service project, host subnet

Note project = var.service_project but subnetwork resolves to the host's subnet. The depends_on matters: without the attach and the IAM binding in place, the create races and fails.

 1resource "google_compute_instance" "app" {
 2  project      = var.service_project
 3  name         = "app-vm-01"
 4  zone         = var.zone
 5  machine_type = "e2-micro"
 6
 7  boot_disk {
 8    initialize_params { image = "debian-cloud/debian-13" }
 9  }
10
11  network_interface {
12    subnetwork         = google_compute_subnetwork.shared.self_link
13    subnetwork_project = var.host_project # the give-away that this is Shared VPC
14    # no access_config → no external IP; reach it over IAP
15  }
16
17  depends_on = [
18    google_compute_shared_vpc_service_project.app,
19    google_compute_subnetwork_iam_member.network_user,
20  ]
21}

cloud-nat.tf — Demo C, one shared egress IP

All three resources live in the host project. google_compute_address reserves a fixed, allow-listable egress IP; the NAT translates the shared subnet, so every no-public-IP VM in every service project egresses through that one address.

 1resource "google_compute_router" "nat_router" {
 2  project = var.host_project
 3  name    = "shared-nat-router"
 4  network = google_compute_network.shared.id
 5  region  = var.region
 6}
 7
 8resource "google_compute_address" "nat_ip" {
 9  project = var.host_project
10  name    = "shared-nat-ip"
11  region  = var.region
12}
13
14resource "google_compute_router_nat" "nat" {
15  project = var.host_project
16  name    = "shared-nat"
17  router  = google_compute_router.nat_router.name
18  region  = var.region
19
20  nat_ip_allocate_option = "MANUAL_ONLY"
21  nat_ips                = [google_compute_address.nat_ip.self_link]
22
23  # NAT only the shared subnet (not "all subnets") — explicit and least-surprise
24  source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS"
25  subnetwork {
26    name                    = google_compute_subnetwork.shared.id
27    source_ip_ranges_to_nat = ["ALL_IP_RANGES"]
28  }
29}

outputs.tf

 1output "host_project" {
 2  value = google_compute_shared_vpc_host_project.host.project
 3}
 4
 5output "shared_subnet" {
 6  value = google_compute_subnetwork.shared.self_link
 7}
 8
 9output "vm_nic_subnetwork" {
10  description = "Should point into the HOST project — the proof Shared VPC is working."
11  value       = google_compute_instance.app.network_interface[0].subnetwork
12}
13
14output "shared_egress_ip" {
15  description = "The one IP every service-project VM egresses through — hand this to allow-lists."
16  value       = google_compute_address.nat_ip.address
17}

Apply

1terraform init
2terraform plan
3terraform apply

⚠️ terraform destroy ordering. The service project must be detached before the host can be disabled, and the subnet can't go while a NIC still uses it. Terraform's dependency graph handles this — but only because of the explicit depends_on above. Without it, destroy fails with "resource in use".


11. Common errors

1. shared-vpc enable fails with a permission error - You do not have roles/compute.xpnAdmin, or you have it on the project instead of the organization. Grant it on the org (step 1).

2. associated-projects add fails - The service project is not under the same organization as the host. Check both with gcloud projects get-ancestors <project-id>.

3. VM creation fails with Required 'compute.subnetworks.use' permission - The classic. The service project is attached, but the user or the service project's default compute service account has no roles/compute.networkUser on the subnet. Step 5.

4. The VM is created but you cannot SSH - The IAP firewall rule is missing or you created it in the service project. Firewall rules go in the host project (step 6).

5. terraform destroy fails with "resource in use" - The service project must be detached before the host can be disabled, and the subnet cannot be deleted while a NIC uses it. The explicit depends_on in the Terraform handles the order - do not remove it.

6. The VM cannot reach the internet or apt-get hangs - It has no public IP (by design). Either enable private_ip_google_access on the subnet for Google APIs, or add the Cloud NAT from steps 8 to 10 for general egress.


Shared VPC is how you give many teams their own projects while keeping one governed network. To summarise -

  1. The host owns the network, subnets, firewall rules and NAT. The service projects own the workloads.
  2. Attaching a service project is not enough - grant roles/compute.networkUser on the subnet to the people and service accounts that create resources.
  3. Firewall rules live in the host project. Always.
  4. One Cloud NAT in the host gives every service project a single egress IP you can hand to a partner to allow-list.

The last post in this series is about the most surgical option of all - exposing just one service across projects, without sharing or peering any network - Google Cloud Private Service Connect.

The Google Cloud networking series -

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

Posts in this series