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
- What is Private Service Connect?
- PSC vs VPC peering vs Shared VPC - where it fits
- The five benefits of PSC
- Producer and consumer - the two roles
- How the traffic flows
- When to use PSC and when not to
- What we are going to build
- Build it step by step (diagrams, gcloud and Terraform for every step)
- gcloud CLI walkthrough - producer then consumer
- The same setup in Terraform
- Common errors
- 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.
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 connects | Exposure | |
|---|---|---|
| VPC peering | two whole networks | everything on both sides |
| Shared VPC | many projects onto one network | one governed network |
| Private Service Connect | a consumer to one published service | just 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
- Private, no peering - Reach the service over an internal IP without joining networks or touching the internet.
- No IP-overlap headaches - Consumer and producer can even use the same ranges, PSC translates for you (peering forbids this).
- 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.
- Massive scale - One published service can serve thousands of consumer endpoints across projects and organizations. No non-transitive peering mesh to manage.
- 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
- 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
- The consumer VM sends a request to the endpoint IP (
10.20.0.100) in its own VPC. - The PSC endpoint forwards it to the producer's service attachment.
- Google NATs the traffic through a dedicated subnet in the producer VPC (the PSC NAT subnet, which is why overlapping ranges are fine).
- 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), abackend-vmrunning 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 separateconsumer-vpc(10.20.0.0/24), a PSC endpoint on10.20.0.100, and aconsumer-vmto test from. - Then from the consumer VM -
curl http://10.20.0.100returns a page served by a web server in the other project's network. Andgcloud compute networks peerings liston both sides is empty.
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): abackend-vmrunning 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 IP10.1.1.100, and aconsumer-vmto 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 liston 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 on10.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.
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/24resource "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"
}gcloud compute instances create backend-vm --project="$PRODUCER" \
--zone="$ZONE" --machine-type=e2-micro --no-address \
--subnet=producer-subnet \
--image-family=debian-13 --image-project=debian-cloud \
--metadata=startup-script='#!/bin/bash
apt-get update && apt-get install -y nginx
echo "Hello from backend-vm over PSC" > /var/www/html/index.html'
gcloud compute instance-groups unmanaged create backend-ig --project="$PRODUCER" --zone="$ZONE"
gcloud compute instance-groups unmanaged add-instances backend-ig --project="$PRODUCER" \
--zone="$ZONE" --instances=backend-vm
gcloud compute instance-groups unmanaged set-named-ports backend-ig --project="$PRODUCER" \
--zone="$ZONE" --named-ports=http:80
gcloud compute firewall-rules create producer-fw-allow-health-check --project="$PRODUCER" \
--network=producer-vpc --direction=INGRESS --action=ALLOW --rules=tcp:80 \
--source-ranges=130.211.0.0/22,35.191.0.0/16resource "google_compute_instance" "backend" {
project = var.producer
name = "backend-vm"
zone = var.zone
machine_type = "e2-micro"
boot_disk { initialize_params { image = "debian-cloud/debian-13" } }
network_interface { subnetwork = google_compute_subnetwork.producer.id }
metadata_startup_script = "apt-get update && apt-get install -y nginx"
}
resource "google_compute_instance_group" "backend" {
project = var.producer
name = "backend-ig"
zone = var.zone
instances = [google_compute_instance.backend.self_link]
named_port { name = "http", port = 80 }
}gcloud compute health-checks create http producer-hc --project="$PRODUCER" \
--region="$REGION" --port=80
gcloud compute backend-services create producer-backend-service --project="$PRODUCER" \
--region="$REGION" --load-balancing-scheme=INTERNAL --protocol=TCP \
--health-checks=producer-hc --health-checks-region="$REGION"
gcloud compute backend-services add-backend producer-backend-service --project="$PRODUCER" \
--region="$REGION" --instance-group=backend-ig --instance-group-zone="$ZONE"
gcloud compute forwarding-rules create producer-ilb-fr --project="$PRODUCER" \
--region="$REGION" --load-balancing-scheme=INTERNAL \
--network=producer-vpc --subnet=producer-subnet \
--backend-service=producer-backend-service --ports=80resource "google_compute_region_backend_service" "producer" {
project = var.producer
name = "producer-backend-service"
region = var.region
load_balancing_scheme = "INTERNAL"
protocol = "TCP"
health_checks = [google_compute_region_health_check.producer.id]
backend { group = google_compute_instance_group.backend.id }
}
resource "google_compute_forwarding_rule" "producer_ilb" {
project = var.producer
name = "producer-ilb-fr"
region = var.region
load_balancing_scheme = "INTERNAL"
network = google_compute_network.producer.id
subnetwork = google_compute_subnetwork.producer.id
backend_service = google_compute_region_backend_service.producer.id
ports = ["80"]
}gcloud compute networks subnets create producer-psc-subnet --project="$PRODUCER" \
--network=producer-vpc --region="$REGION" --range=10.10.100.0/24 \
--purpose=PRIVATE_SERVICE_CONNECT
gcloud compute firewall-rules create producer-fw-allow-psc --project="$PRODUCER" \
--network=producer-vpc --direction=INGRESS --action=ALLOW --rules=tcp:80 \
--source-ranges=10.10.100.0/24resource "google_compute_subnetwork" "psc_nat" {
project = var.producer
name = "producer-psc-subnet"
network = google_compute_network.producer.id
region = var.region
ip_cidr_range = "10.10.100.0/24"
purpose = "PRIVATE_SERVICE_CONNECT"
}# the published "door" — targets the ILB forwarding rule, NATs via the PSC subnet
gcloud compute service-attachments create producer-service-attachment --project="$PRODUCER" \
--region="$REGION" \
--producer-forwarding-rule=producer-ilb-fr \
--connection-preference=ACCEPT_AUTOMATIC \
--nat-subnets=producer-psc-subnetresource "google_compute_service_attachment" "producer" {
project = var.producer
name = "producer-service-attachment"
region = var.region
connection_preference = "ACCEPT_AUTOMATIC"
nat_subnets = [google_compute_subnetwork.psc_nat.id]
target_service = google_compute_forwarding_rule.producer_ilb.id
}# a completely separate network — never peered with the producer
gcloud compute networks create consumer-vpc --project="$CONSUMER" --subnet-mode=custom
gcloud compute networks subnets create consumer-subnet --project="$CONSUMER" \
--network=consumer-vpc --region="$REGION" --range=10.20.0.0/24resource "google_compute_network" "consumer" {
project = var.consumer
name = "consumer-vpc"
auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "consumer" {
project = var.consumer
name = "consumer-subnet"
network = google_compute_network.consumer.id
region = var.region
ip_cidr_range = "10.20.0.0/24"
}gcloud compute addresses create psc-endpoint-ip --project="$CONSUMER" \
--region="$REGION" --subnet=consumer-subnet --addresses=10.20.0.100
export SA_URI=$(gcloud compute service-attachments describe producer-service-attachment \
--project="$PRODUCER" --region="$REGION" --format="value(selfLink)")
# the endpoint: a forwarding rule that TARGETS the service attachment
gcloud compute forwarding-rules create psc-endpoint --project="$CONSUMER" \
--region="$REGION" --network=consumer-vpc --subnet=consumer-subnet \
--address=psc-endpoint-ip \
--target-service-attachment="$SA_URI"resource "google_compute_address" "psc_endpoint" {
project = var.consumer
name = "psc-endpoint-ip"
region = var.region
subnetwork = google_compute_subnetwork.consumer.id
address_type = "INTERNAL"
address = "10.20.0.100"
}
resource "google_compute_forwarding_rule" "psc_endpoint" {
project = var.consumer
name = "psc-endpoint"
region = var.region
network = google_compute_network.consumer.id
subnetwork = google_compute_subnetwork.consumer.id
ip_address = google_compute_address.psc_endpoint.id
target = google_compute_service_attachment.producer.id
load_balancing_scheme = ""
}gcloud compute instances create consumer-vm --project="$CONSUMER" \
--zone="$ZONE" --machine-type=e2-micro --no-address \
--subnet=consumer-subnet \
--image-family=debian-13 --image-project=debian-cloud
gcloud compute firewall-rules create consumer-fw-allow-iap-ssh --project="$CONSUMER" \
--network=consumer-vpc --direction=INGRESS --action=ALLOW --rules=tcp:22 \
--source-ranges=35.235.240.0/20resource "google_compute_instance" "consumer" {
project = var.consumer
name = "consumer-vm"
zone = var.zone
machine_type = "e2-micro"
boot_disk { initialize_params { image = "debian-cloud/debian-13" } }
network_interface { subnetwork = google_compute_subnetwork.consumer.id }
}# the private hop — page comes from a VM in another project's network
gcloud compute ssh consumer-vm --project="$CONSUMER" --zone="$ZONE" --tunnel-through-iap \
--command="curl -s http://10.20.0.100"
# → Hello from backend-vm over PSC
# proof there is NO peering anywhere
gcloud compute networks peerings list --project="$CONSUMER"
gcloud compute networks peerings list --project="$PRODUCER"output "psc_endpoint_ip" {
value = google_compute_address.psc_endpoint.address
}
output "psc_connection_status" {
value = google_compute_forwarding_rule.psc_endpoint.psc_connection_status
}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 -
- The producer publishes a service attachment in front of an internal load balancer, through a dedicated PSC NAT subnet.
- The consumer creates a PSC endpoint - a forwarding rule with an internal IP in its own subnet - that targets the service attachment.
- There is no peering, no route exchange and no IP-overlap problem. The consumer sees only the one service, one-way.
- 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 -
- Google Cloud VPC peering - hub and spoke
- Google Cloud cross-project VPC peering
- Google Cloud Organization setup with Cloud Identity (free)
- Google Cloud Shared VPC - one network, many projects
- Google Cloud Private Service Connect (this post)
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