Google Cloud Private Service Connect (PSC) Explained - Publish a Service with a Service Attachment and Consume It Through a PSC Endpoint


This is the last post of my Google Cloud networking series, and it is about the tool I find the most elegant of them all - Private Service Connect, or PSC. With VPC peering we connected two whole networks. With Shared VPC we put many projects on one network. PSC does something much more surgical - it lets one team publish a single service, and any other team consume it over a private IP inside their own VPC, with the two networks never being joined at all.

No peering, nothing exposed to the internet, and you do not even have to care about overlapping IP ranges. This is also the exact mechanism behind private connectivity to Cloud SQL, Memorystore and a lot of partner SaaS products, so once you understand it here you will recognise it everywhere.

We will play both roles - publish a small web service behind an internal load balancer in a producer project, then connect to it from a consumer project through a PSC endpoint, and prove the consumer reaches it over a private IP with no peering on either side.

Table of Content

  1. What is Private Service Connect?
  2. PSC vs VPC peering vs Shared VPC - where it fits
  3. The five benefits of PSC
  4. Producer and consumer - the two roles
  5. How the traffic flows
  6. When to use PSC and when not to
  7. What we are going to build
  8. Build it step by step (diagrams, gcloud and Terraform for every step)
  9. gcloud CLI walkthrough - producer then consumer
  10. The same setup in Terraform
  11. Common errors
  12. Conclusion


1. What is Private Service Connect?

Private Service Connect (PSC) lets one team publish a service and any other team consume it over a private internal IP in their own VPC - with no peering, nothing exposed to the internet, and no worrying about overlapping IP ranges. The producer publishes once; thousands of consumers connect privately.

In one sentence - you reach a service as if it lived in your own network, over a single internal IP, without ever joining the two networks together. That is PSC in a nutshell.

🔍 Click to zoom WHY IT EXISTSPeering wires whole networks together — PSC exposes one serviceSame private connectivity, but nothing else on the network is touchedTHE PEERING WAYconsumer-vpcVPC NETWORKconsumer-subnet · 10.10.0.0/24vm-a · 10.10.0.2producer-vpcVPC NETWORKproducer-subnet · 10.20.0.0/24vm-b · 10.20.0.2PEEREDevery resource on both sides can reach every other — both ways✗ no overlapping IP ranges · ✗ whole networks exposed · ✗ non-transitiveTHE PSC WAYconsumer-vpcVPC NETWORKconsumer-subnet · 10.10.0.0/24consumer-vm · 10.10.0.2PSC endpoint10.10.0.100 · private IPserviceattachmentthe one doorproducer-vpcVPC NETWORKinternal load balancerfronts the backendbackend-vm · 10.10.0.5🔒 other-subnet · NOT exposed to the consumerone-way✓ overlapping IPs are fine (both 10.10.0.0/24) · ✓ only the service is reachable · ✓ one-way
Before - peering the whole networks to reach one service. After - one published service, one private endpoint. Click to zoom


2. PSC vs VPC peering vs Shared VPC - where it fits

Each tool in this series connects things differently, and PSC is the most surgical -

What it connectsExposure
VPC peeringtwo whole networkseverything on both sides
Shared VPCmany projects onto one networkone governed network
Private Service Connecta consumer to one published servicejust that service, one-way

Peering and Shared VPC are about networks. PSC is about a service - you expose exactly one thing, and the two networks stay completely separate.


3. The five benefits of PSC

🔍 Click to zoom THE PAYOFFFive wins from Private Service ConnectWhy enterprises publish services with PSC instead of peeringPSC🔒Private, no peeringreach a service over an internal IP without joining networks♻️No IP-overlap headachesconsumer and producer can reuse the same ranges — PSC translates🎯Least exposureonly the one published service is reachable, and only one-way📈Massive scaleone service → thousands of consumer endpoints across projects🌐Cross-org and managedsame model powers Cloud SQL, Memorystore and partner SaaS
The five benefits of Private Service Connect - click to zoom
  1. Private, no peering - Reach the service over an internal IP without joining networks or touching the internet.
  2. No IP-overlap headaches - Consumer and producer can even use the same ranges, PSC translates for you (peering forbids this).
  3. Least exposure - Only the one published service is reachable, and only one-way (consumer to producer). Nothing else on the producer's network is visible.
  4. Massive scale - One published service can serve thousands of consumer endpoints across projects and organizations. No non-transitive peering mesh to manage.
  5. Cross-org and managed services - The exact same model powers Cloud SQL, Memorystore and partner SaaS. Learn it once, use it everywhere.


4. Producer and consumer - the two roles

🔍 Click to zoom PRODUCER vs CONSUMERWho publishes, who connectsThe producer owns the service · the consumer just gets a private door to itserviceattachmentthe published doorProducer Teampublishes the service🔑 owns backend + LB📁 PRODUCER · producer VPCinternal load balancerfronts the backendbackend-vm · web serverpublishesConsumer Teamconnects to it🚪 gets a private IP only📁 CONSUMER · consumer VPCPSC endpoint · 10.1.1.100a private internal IPconsumer-vmconnectsconsumer never sees the producer's network — only the endpoint IP
The producer publishes a service attachment, the consumer creates a PSC endpoint - click to zoom
  • Producer - Runs the service (a backend behind an internal load balancer) and publishes it with a service attachment. The producer decides who is allowed to connect.
  • Consumer - Creates a PSC endpoint - a forwarding rule with a private internal IP in the consumer's own subnet, which points at the producer's service attachment. Traffic to that IP reaches the service, privately.

The service attachment is the published "door". It is the only thing the consumer can see of the producer.


5. How the traffic flows

🔍 Click to zoom HOW A REQUEST FLOWSOne private hop from consumer to serviceconsumer VM → PSC endpoint → service attachment → producer LB → backendconsumer-vmin the consumer VPCPSC endpoint10.1.1.100 · internal IPservice attachmentthe published doorproducer ILBinternal load balancerbackend-vm · web serverprivate · over Google's backbone · no peering · one-way (consumer → producer)
Consumer VM to PSC endpoint IP, through the PSC NAT subnet, to the internal load balancer and the backend - click to zoom
  1. The consumer VM sends a request to the endpoint IP (10.20.0.100) in its own VPC.
  2. The PSC endpoint forwards it to the producer's service attachment.
  3. Google NATs the traffic through a dedicated subnet in the producer VPC (the PSC NAT subnet, which is why overlapping ranges are fine).
  4. The producer's internal load balancer sends it to a healthy backend.

The producer's backend only ever sees a source IP from the PSC NAT subnet. It never sees the consumer's network.



6. When to use PSC and when not to

Use PSC when -

  • You want to expose a single service to other teams, projects or organizations without merging networks.
  • Consumer and producer have overlapping IP ranges, or you simply do not want the coupling of peering.
  • You are consuming a managed service (Cloud SQL, Memorystore) or a partner SaaS over private IPs.

Reach for something else when -

  • Two networks genuinely need full, bidirectional connectivity - VPC peering.
  • Many projects should share one network - Shared VPC.

7. What we are going to build

  • Producer project jhooq-prod-svc-producer-01 - producer-vpc (10.10.0.0/24), a backend-vm running nginx with no public IP, an internal passthrough load balancer, a PSC NAT subnet (10.10.100.0/24) and a service attachment.
  • Consumer project jhooq-prod-svc-consumer-01 - a completely separate consumer-vpc (10.20.0.0/24), a PSC endpoint on 10.20.0.100, and a consumer-vm to test from.
  • Then from the consumer VM - curl http://10.20.0.100 returns a page served by a web server in the other project's network. And gcloud compute networks peerings list on both sides is empty.
🔍 Click to zoom 📁 CONSUMER VPCconsumer-vmPSC endpoint10.1.1.100 · private IPserviceattachmentthe published door📁 PRODUCER VPCinternal load balancerfronts the backendbackend-vm · web serverone private endpoint → one published service · networks never joined
Producer publishes, consumer connects - two networks that are never joined. Click to zoom

Prerequisites - two projects with billing (they do not have to be in the same organization, PSC works across orgs), gcloud logged in as Owner of both, and for the Terraform part Terraform installed with the Google provider.

The whole point of PSC only lands when you see the consumer reach the service over a private IP — with the two networks provably never joined. Here's what to show on camera.

The demo — one private hop, two separate networks

You'll stand up both sides:

  • Producer (jhooq-prod-svc-producer-01): a backend-vm running a tiny web server, behind an internal load balancer, published through a service attachment.
  • Consumer (jhooq-prod-svc-consumer-01): a PSC endpoint with a private IP 10.1.1.100, and a consumer-vm to test from.

Then, from the consumer VM:

1curl http://10.1.1.100

"That IP lives in the consumer's VPC — but the page came from a web server in a different project's network. No peering, no public IP, no shared network. Just one published service, reached over one private IP."

Two things to point at while narrating

  • The networks are never joined. Run gcloud compute networks peerings list on both sides — it's empty. There is no peering; PSC is not peering.
  • The consumer sees only the endpoint. It has no route to the producer's subnet, no visibility of backend-vm, nothing but the one service on 10.1.1.100. Least exposure, by design.

Where this goes next (future episodes)

  • PSC for Google APIs — the same idea, but the "service" is googleapis.com: reach Google APIs over a private internal endpoint instead of the public internet.
  • PSC to Cloud SQL — connect privately to a Google-managed database through a service attachment Google publishes for you.

For now we cover the publish-and-consume core end-to-end — the pattern everything else builds on.


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

Nine steps - five on the producer side, four on the consumer side. Click a step in the rail or use Prev / Next or the arrow keys, and click the diagram to zoom.

🔍 Click to zoomSTEP 1 / 9Producer networkproducer-vpc + producer-subnet 10.10.0.0/24📁 PROJECT · jhooq-prod-svc-producer-01producer-vpcVPC NETWORKproducer-subnet10.10.0.0/24NEW📁 PROJECT · jhooq-prod-svc-consumer-01the consumer connects here— a completely separate network
▶ CLI command for this step
gcloud compute networks create producer-vpc --project="$PRODUCER" --subnet-mode=custom

gcloud compute networks subnets create producer-subnet --project="$PRODUCER" \
  --network=producer-vpc --region="$REGION" --range=10.10.0.0/24
✦ Terraform for this step
resource "google_compute_network" "producer" {
  project                 = var.producer
  name                    = "producer-vpc"
  auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "producer" {
  project       = var.producer
  name          = "producer-subnet"
  network       = google_compute_network.producer.id
  region        = var.region
  ip_cidr_range = "10.10.0.0/24"
}
Step 1 of 9


9. gcloud CLI walkthrough - producer then consumer

Publish the service in the producer project, then consume it privately from the consumer project through a PSC endpoint. Run these in order, every command is copy-paste ready.

Publish a service in the producer project, then consume it privately from the consumer project through a PSC endpoint. Run these in order — every command is copy-paste ready.

0 · Set your variables

Both projects must be under the same org (or at least reachable via billing you control). PSC itself works across orgs — we keep it to two projects for the demo.

1export PRODUCER=jhooq-prod-svc-producer-01
2export CONSUMER=jhooq-prod-svc-consumer-01
3export REGION=europe-north2
4export ZONE=europe-north2-a
5
6gcloud services enable compute.googleapis.com --project="$PRODUCER"
7gcloud services enable compute.googleapis.com --project="$CONSUMER"

Producer — publish the service

1 · Producer VPC + subnet

1gcloud compute networks create producer-vpc --project="$PRODUCER" --subnet-mode=custom
2
3gcloud compute networks subnets create producer-subnet --project="$PRODUCER" \
4  --network=producer-vpc --region="$REGION" --range=10.10.0.0/24

2 · Backend VM (a tiny web server) + instance group

The backend has no public IP. A startup script installs nginx; an unmanaged instance group wraps the VM so the load balancer can use it.

 1gcloud compute instances create backend-vm --project="$PRODUCER" \
 2  --zone="$ZONE" --machine-type=e2-micro --no-address \
 3  --subnet=producer-subnet \
 4  --image-family=debian-13 --image-project=debian-cloud \
 5  --metadata=startup-script='#!/bin/bash
 6apt-get update && apt-get install -y nginx
 7echo "Hello from backend-vm — served privately over Private Service Connect" > /var/www/html/index.html'
 8
 9gcloud compute instance-groups unmanaged create backend-ig --project="$PRODUCER" --zone="$ZONE"
10gcloud compute instance-groups unmanaged add-instances backend-ig --project="$PRODUCER" \
11  --zone="$ZONE" --instances=backend-vm
12gcloud compute instance-groups unmanaged set-named-ports backend-ig --project="$PRODUCER" \
13  --zone="$ZONE" --named-ports=http:80

Firewall: allow Google's health-check ranges and IAP SSH to reach the backend:

1gcloud compute firewall-rules create producer-fw-allow-health-check --project="$PRODUCER" \
2  --network=producer-vpc --direction=INGRESS --action=ALLOW --rules=tcp:80 \
3  --source-ranges=130.211.0.0/22,35.191.0.0/16
4
5gcloud compute firewall-rules create producer-fw-allow-iap-ssh --project="$PRODUCER" \
6  --network=producer-vpc --direction=INGRESS --action=ALLOW --rules=tcp:22 \
7  --source-ranges=35.235.240.0/20

3 · Internal load balancer

An internal passthrough Network Load Balancer: health check → backend service → forwarding rule. That forwarding rule is what the service attachment will publish.

 1gcloud compute health-checks create http producer-hc --project="$PRODUCER" \
 2  --region="$REGION" --port=80
 3
 4gcloud compute backend-services create producer-backend-service --project="$PRODUCER" \
 5  --region="$REGION" --load-balancing-scheme=INTERNAL --protocol=TCP \
 6  --health-checks=producer-hc --health-checks-region="$REGION"
 7
 8gcloud compute backend-services add-backend producer-backend-service --project="$PRODUCER" \
 9  --region="$REGION" --instance-group=backend-ig --instance-group-zone="$ZONE"
10
11gcloud compute forwarding-rules create producer-ilb-fr --project="$PRODUCER" \
12  --region="$REGION" --load-balancing-scheme=INTERNAL \
13  --network=producer-vpc --subnet=producer-subnet \
14  --backend-service=producer-backend-service --ports=80

4 · PSC NAT subnet

A dedicated subnet reserved for PSC. Google uses it to NAT consumer traffic — this is why the consumer and producer can even have overlapping ranges.

1gcloud compute networks subnets create producer-psc-subnet --project="$PRODUCER" \
2  --network=producer-vpc --region="$REGION" --range=10.10.100.0/24 \
3  --purpose=PRIVATE_SERVICE_CONNECT
4
5# let the NAT'd traffic reach the backend
6gcloud compute firewall-rules create producer-fw-allow-psc --project="$PRODUCER" \
7  --network=producer-vpc --direction=INGRESS --action=ALLOW --rules=tcp:80 \
8  --source-ranges=10.10.100.0/24

5 · Service attachment — publish it

The service attachment is the published door: it points at the ILB forwarding rule and uses the PSC NAT subnet. ACCEPT_AUTOMATIC accepts any consumer (tighten to ACCEPT_MANUAL in prod).

1gcloud compute service-attachments create producer-service-attachment --project="$PRODUCER" \
2  --region="$REGION" \
3  --producer-forwarding-rule=producer-ilb-fr \
4  --connection-preference=ACCEPT_AUTOMATIC \
5  --nat-subnets=producer-psc-subnet
6
7# grab its full URI — the consumer needs this
8gcloud compute service-attachments describe producer-service-attachment \
9  --project="$PRODUCER" --region="$REGION" --format="value(selfLink)"

Consumer — connect to it

6 · Consumer VPC + subnet

A completely separate network. It never peers with the producer.

1gcloud compute networks create consumer-vpc --project="$CONSUMER" --subnet-mode=custom
2
3gcloud compute networks subnets create consumer-subnet --project="$CONSUMER" \
4  --network=consumer-vpc --region="$REGION" --range=10.20.0.0/24

7 · PSC endpoint

Reserve an internal IP, then create a forwarding rule that targets the service attachment. That IP becomes the private front door to the producer's service.

 1gcloud compute addresses create psc-endpoint-ip --project="$CONSUMER" \
 2  --region="$REGION" --subnet=consumer-subnet --addresses=10.20.0.100
 3
 4export SA_URI=$(gcloud compute service-attachments describe producer-service-attachment \
 5  --project="$PRODUCER" --region="$REGION" --format="value(selfLink)")
 6
 7gcloud compute forwarding-rules create psc-endpoint --project="$CONSUMER" \
 8  --region="$REGION" --network=consumer-vpc --subnet=consumer-subnet \
 9  --address=psc-endpoint-ip \
10  --target-service-attachment="$SA_URI"

8 · Consumer test VM

1gcloud compute instances create consumer-vm --project="$CONSUMER" \
2  --zone="$ZONE" --machine-type=e2-micro --no-address \
3  --subnet=consumer-subnet \
4  --image-family=debian-13 --image-project=debian-cloud
5
6gcloud compute firewall-rules create consumer-fw-allow-iap-ssh --project="$CONSUMER" \
7  --network=consumer-vpc --direction=INGRESS --action=ALLOW --rules=tcp:22 \
8  --source-ranges=35.235.240.0/20

9 · Verify — the private hop

SSH into the consumer VM over IAP and curl the endpoint IP — the page comes from a web server in a different project's network, reached over a private internal IP:

1gcloud compute ssh consumer-vm --project="$CONSUMER" --zone="$ZONE" --tunnel-through-iap \
2  --command="curl -s http://10.20.0.100"
3# → Hello from backend-vm — served privately over Private Service Connect

Two things to point out:

1# there is NO peering on either side — PSC is not peering
2gcloud compute networks peerings list --project="$CONSUMER"
3gcloud compute networks peerings list --project="$PRODUCER"
4
5# the endpoint status is ACCEPTED
6gcloud compute forwarding-rules describe psc-endpoint --project="$CONSUMER" \
7  --region="$REGION" --format="value(pscConnectionStatus)"

Tear it down

Consumer first (endpoint + IP + VM), then producer (attachment → LB → NAT subnet → backend → network).

 1# consumer
 2gcloud compute forwarding-rules delete psc-endpoint --project="$CONSUMER" --region="$REGION" --quiet
 3gcloud compute addresses delete psc-endpoint-ip --project="$CONSUMER" --region="$REGION" --quiet
 4gcloud compute instances delete consumer-vm --project="$CONSUMER" --zone="$ZONE" --quiet
 5gcloud compute firewall-rules delete consumer-fw-allow-iap-ssh --project="$CONSUMER" --quiet
 6gcloud compute networks subnets delete consumer-subnet --project="$CONSUMER" --region="$REGION" --quiet
 7gcloud compute networks delete consumer-vpc --project="$CONSUMER" --quiet
 8
 9# producer
10gcloud compute service-attachments delete producer-service-attachment --project="$PRODUCER" --region="$REGION" --quiet
11gcloud compute forwarding-rules delete producer-ilb-fr --project="$PRODUCER" --region="$REGION" --quiet
12gcloud compute backend-services delete producer-backend-service --project="$PRODUCER" --region="$REGION" --quiet
13gcloud compute health-checks delete producer-hc --project="$PRODUCER" --region="$REGION" --quiet
14gcloud compute instance-groups unmanaged delete backend-ig --project="$PRODUCER" --zone="$ZONE" --quiet
15gcloud compute instances delete backend-vm --project="$PRODUCER" --zone="$ZONE" --quiet
16gcloud compute firewall-rules delete producer-fw-allow-psc producer-fw-allow-health-check producer-fw-allow-iap-ssh --project="$PRODUCER" --quiet
17gcloud compute networks subnets delete producer-psc-subnet producer-subnet --project="$PRODUCER" --region="$REGION" --quiet
18gcloud compute networks delete producer-vpc --project="$PRODUCER" --quiet

10. The same setup in Terraform

The same publish-and-consume flow in Terraform. Every resource carries an explicit project, so a single provider handles both the producer and the consumer. The metadata_startup_script which installs nginx is a plain heredoc here - on a bigger project I would move it into a Terraform template file.

The same publish-and-consume flow in Terraform. Every resource carries an explicit project, so a single provider handles both the producer and consumer projects.

providers.tf + variables.tf

 1terraform {
 2  required_providers {
 3    google = { source = "hashicorp/google", version = "~> 6.0" }
 4  }
 5}
 6
 7provider "google" {
 8  region = var.region
 9}
10
11variable "producer" { type = string, default = "jhooq-prod-svc-producer-01" }
12variable "consumer" { type = string, default = "jhooq-prod-svc-consumer-01" }
13variable "region"   { type = string, default = "europe-north2" }
14variable "zone"     { type = string, default = "europe-north2-a" }

producer.tf — publish the service

Network + the PSC NAT subnet

 1resource "google_compute_network" "producer" {
 2  project                 = var.producer
 3  name                    = "producer-vpc"
 4  auto_create_subnetworks = false
 5}
 6
 7resource "google_compute_subnetwork" "producer" {
 8  project       = var.producer
 9  name          = "producer-subnet"
10  network       = google_compute_network.producer.id
11  region        = var.region
12  ip_cidr_range = "10.10.0.0/24"
13}
14
15# dedicated subnet Google NATs consumer traffic through — the overlap magic
16resource "google_compute_subnetwork" "psc_nat" {
17  project       = var.producer
18  name          = "producer-psc-subnet"
19  network       = google_compute_network.producer.id
20  region        = var.region
21  ip_cidr_range = "10.10.100.0/24"
22  purpose       = "PRIVATE_SERVICE_CONNECT"
23}

Backend VM + instance group

 1resource "google_compute_instance" "backend" {
 2  project      = var.producer
 3  name         = "backend-vm"
 4  zone         = var.zone
 5  machine_type = "e2-micro"
 6
 7  boot_disk { initialize_params { image = "debian-cloud/debian-13" } }
 8  network_interface { subnetwork = google_compute_subnetwork.producer.id } # no access_config = no public IP
 9
10  metadata_startup_script = <<-EOT
11    apt-get update && apt-get install -y nginx
12    echo "Hello from backend-vm — served privately over Private Service Connect" > /var/www/html/index.html
13  EOT
14}
15
16resource "google_compute_instance_group" "backend" {
17  project   = var.producer
18  name      = "backend-ig"
19  zone      = var.zone
20  instances = [google_compute_instance.backend.self_link]
21  named_port { name = "http", port = 80 }
22}

Internal load balancer (health check → backend service → forwarding rule)

 1resource "google_compute_region_health_check" "producer" {
 2  project = var.producer
 3  name    = "producer-hc"
 4  region  = var.region
 5  http_health_check { port = 80 }
 6}
 7
 8resource "google_compute_region_backend_service" "producer" {
 9  project               = var.producer
10  name                  = "producer-backend-service"
11  region                = var.region
12  load_balancing_scheme = "INTERNAL"
13  protocol              = "TCP"
14  health_checks         = [google_compute_region_health_check.producer.id]
15  backend { group = google_compute_instance_group.backend.id }
16}
17
18resource "google_compute_forwarding_rule" "producer_ilb" {
19  project               = var.producer
20  name                  = "producer-ilb-fr"
21  region                = var.region
22  load_balancing_scheme = "INTERNAL"
23  network               = google_compute_network.producer.id
24  subnetwork            = google_compute_subnetwork.producer.id
25  backend_service       = google_compute_region_backend_service.producer.id
26  ports                 = ["80"]
27}

Firewall + the service attachment

 1resource "google_compute_firewall" "allow_health_check" {
 2  project       = var.producer
 3  name          = "producer-fw-allow-health-check"
 4  network       = google_compute_network.producer.id
 5  direction     = "INGRESS"
 6  source_ranges = ["130.211.0.0/22", "35.191.0.0/16"]
 7  allow { protocol = "tcp", ports = ["80"] }
 8}
 9
10resource "google_compute_firewall" "allow_psc" {
11  project       = var.producer
12  name          = "producer-fw-allow-psc"
13  network       = google_compute_network.producer.id
14  direction     = "INGRESS"
15  source_ranges = ["10.10.100.0/24"] # the PSC NAT subnet
16  allow { protocol = "tcp", ports = ["80"] }
17}
18
19resource "google_compute_service_attachment" "producer" {
20  project               = var.producer
21  name                  = "producer-service-attachment"
22  region                = var.region
23  connection_preference = "ACCEPT_AUTOMATIC"
24  nat_subnets           = [google_compute_subnetwork.psc_nat.id]
25  target_service        = google_compute_forwarding_rule.producer_ilb.id
26  enable_proxy_protocol = false
27}

consumer.tf — connect to it

The endpoint is a forwarding rule whose target is the producer's service attachment.

 1resource "google_compute_network" "consumer" {
 2  project                 = var.consumer
 3  name                    = "consumer-vpc"
 4  auto_create_subnetworks = false
 5}
 6
 7resource "google_compute_subnetwork" "consumer" {
 8  project       = var.consumer
 9  name          = "consumer-subnet"
10  network       = google_compute_network.consumer.id
11  region        = var.region
12  ip_cidr_range = "10.20.0.0/24"
13}
14
15resource "google_compute_address" "psc_endpoint" {
16  project      = var.consumer
17  name         = "psc-endpoint-ip"
18  region       = var.region
19  subnetwork   = google_compute_subnetwork.consumer.id
20  address_type = "INTERNAL"
21  address      = "10.20.0.100"
22}
23
24# the PSC endpoint: a forwarding rule that targets the service attachment
25resource "google_compute_forwarding_rule" "psc_endpoint" {
26  project               = var.consumer
27  name                  = "psc-endpoint"
28  region                = var.region
29  network               = google_compute_network.consumer.id
30  subnetwork            = google_compute_subnetwork.consumer.id
31  ip_address            = google_compute_address.psc_endpoint.id
32  target                = google_compute_service_attachment.producer.id
33  load_balancing_scheme = "" # empty = a PSC endpoint, not a load balancer
34}

outputs.tf

1output "psc_endpoint_ip" {
2  description = "curl this from a consumer VM to reach the producer's service privately."
3  value       = google_compute_address.psc_endpoint.address
4}
5
6output "psc_connection_status" {
7  value = google_compute_forwarding_rule.psc_endpoint.psc_connection_status
8}

Apply

1terraform init
2terraform apply

⚠️ Ordering. The consumer's endpoint depends on the producer's service attachment, which depends on the ILB forwarding rule. Terraform's graph handles it because each resource references the previous by .id — keep those references rather than hard-coding names.


11. Common errors

1. The endpoint status is PENDING instead of ACCEPTED - The service attachment uses ACCEPT_MANUAL and the consumer project is not on its accept list. Either switch to ACCEPT_AUTOMATIC for the lab, or add the consumer project with --consumer-accept-list.

2. curl to the endpoint IP times out - Nine times out of ten it is the firewall in the producer - the backend must allow traffic from the PSC NAT subnet (10.10.100.0/24) and from Google's health-check ranges (130.211.0.0/22, 35.191.0.0/16). If the health check fails, the load balancer has no healthy backend and drops everything.

3. The subnetwork must have purpose PRIVATE_SERVICE_CONNECT - You pointed --nat-subnets at a normal subnet. The NAT subnet must be created with --purpose=PRIVATE_SERVICE_CONNECT and must not be used for anything else.

4. Invalid value for field 'resource.target' when creating the endpoint - The service attachment URI is wrong or belongs to a different region. The endpoint and the service attachment must be in the same region. Copy the selfLink from service-attachments describe.

5. The backend VM has no internet, so apt-get install nginx in the startup script fails - It has no public IP by design. Give the producer subnet Private Google Access plus a Cloud NAT (see the Shared VPC post, steps 8 to 10), or bake nginx into the image.


12. Conclusion

Private Service Connect is the most precise tool in the box - one published service, one private endpoint, two networks that stay completely separate. To summarise -

  1. The producer publishes a service attachment in front of an internal load balancer, through a dedicated PSC NAT subnet.
  2. The consumer creates a PSC endpoint - a forwarding rule with an internal IP in its own subnet - that targets the service attachment.
  3. There is no peering, no route exchange and no IP-overlap problem. The consumer sees only the one service, one-way.
  4. It is the same model Google uses for Cloud SQL and Memorystore private connectivity, and the one to reach for when you want to share a service without sharing a network.

That completes the Google Cloud networking series. If you want to go further, the natural next topics are PSC for Google APIs (reach googleapis.com over a private endpoint) and PSC to Cloud SQL - let me know in the comments which one you want first.

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 - one network, many projects
  5. Google Cloud Private Service Connect (this post)

Posts in this series