Hedronite · Ops Lesson · 01-Earth-DevOps / Terraform · Sun 2026-08-30

Terraform GCP Private Google Access — the access that is not a public IP

A public IP is a door to the internet. Private Google Access is a door to Google APIs. The subnet holds the latch. The instance has no access_config.

Lesson Class: Ops (DevOps + Terraform + GCP Private Google Access)
Cloud Referent: GCP PGA — google_compute_subnetwork.private_ip_google_access + no access_config. Rebalances after AWS STS 08-27 and Cloud SQL 08-24.
Paired Dev: Terratest CopyTerraformFolderToTemp + gcp.FetchInstance empty AccessConfigs
Paired Cert: Pro-depth Lab 18: count / one() / try()
Grounding: gcp-ace-pca PGA row · PCA-Notes three doors · Brikman Ch.5 count referenced
The latch
private_ip_google_access lives on the subnet. The instance does not own it.
The door
A missing access_config is no public IP. An empty nest is one.
The proof
Describe the NIC and the subnet. output.pga is a pointer, not the verdict.
A public IP is a door to the internet. Private Google Access is a door to Google APIs. The subnet holds the latch.

<!-- hal:authoritative:yaml -->

A public IP is a door to the internet. Private Google Access is a door to Google APIs. The subnet holds the latch. The instance has no access_config.

§I — Frame

Thursday this arc asked the aws plugin to assume a role instead of pasting a key. Three days before that, the google plugin had to answer a Cloud SQL settings nest. Three days before that, terraform output kept Tuesday's Azure URL. Today's overlay is GCP on purpose, and it is not Cloud SQL again. The last three TF Ops visits sat on AWS IAM, GCP SQL, Azure Storage. Networking is the rebalance, and the Bootcamp ACE/PCA cheatsheet actually names the row: Private Google Access. VMs without a public IP can reach Google APIs.

You open a google_compute_instance block. You type access_config {} because the first tutorial did, because gcloud compute instances create did, because SSH from your laptop is easier when the VM has an ephemeral address. The plan runs. The apply writes. The instance can curl storage.googleapis.com the same way it can curl GitHub. Nothing about that path was private. You authenticated the VM to the internet.

Coin it: the access that is not a public IP.

This is not 08-24. That lesson isolated a nest the google plugin published for Cloud SQL. private_ip_google_access is also a field the google plugin published. The isolation that matters is not the field grammar. The isolation is which door the field opens: Google APIs, from a VM that has no public address.

This is not 08-21. Azure public_network_access_enabled was a grant on a storage account. Today's flag lives on the subnet. The instance can still grow a public IP in the same apply. A census that only prints the subnet flag will call the VM private while access_config still minted an address.

This is not 08-15. Workload Identity binds a GSA to a KSA. That is who the Pod is. Today's question is how a Compute Engine VM without an external IP reaches storage.googleapis.com at all. Identity is a later shirt. Reach is this one.

The Bootcamp ACE/PCA Networking table is one clause: Private Google Access, VMs without public IP can reach Google APIs, AWS equivalent VPC Endpoints (gcp-ace-pca.md). The Digital Leader table says the same mapping with a fourth cell: VMs without external IPs. The PCA notes put the object in a list of private-service stuffs and refuse to let it collapse into Private Service Connect or Serverless VPC Access. Three doors. One flag on the subnet.

§II — Foundations: four facts about the door

Fact one. The flag lives on the subnet. The instance does not own it.

resource "google_compute_network" "vpc" {
  name                    = "app"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "private" {
  name                     = "private"
  ip_cidr_range            = "10.2.0.0/24"
  region                   = "us-central1"
  network                  = google_compute_network.vpc.id
  private_ip_google_access = true
}

auto_create_subnetworks = false is the production default the ACE/PCA notes imply when they tell you the default network exists and you delete it. A custom VPC does not invent PGA. You set private_ip_google_access on each subnet that should have it. A second subnet in the same VPC can stay false. The VM inherits the door from the subnet it sits on, not from a label you put on the instance, not from a comment in main.tf.

gcp-core-services names VPC as the virtual private network and stops. It does not name PGA. That is not a gap in the lesson. That is the reason the ACE/PCA row exists: the core list will not save you on the exam trap.

**Fact two. No access_config means no public IP. An empty access_config means one.**

resource "google_compute_instance" "app" {
  name         = "app"
  machine_type = "e2-micro"
  zone         = "us-central1-a"

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-12"
    }
  }

  network_interface {
    subnetwork = google_compute_subnetwork.private.id
  }
}

The google plugin treats a missing access_config nest as "do not assign an external address." An empty nest is the opposite request: assign an ephemeral one. The two HCL shapes look like cousins. They are not. One VM can reach the internet. One VM cannot. Both can sit on a subnet whose PGA flag is true.

If/then: if the instance has a public IP, it reaches Google APIs the ordinary way and PGA is idle. If the instance has no public IP and PGA is false, Google API packets have nowhere to go. If the instance has no public IP and PGA is true, Google APIs answer and the public internet does not. The exam and the outage both live in the idle case: you set the flag, you also left access_config {} in the file, and you thought the VM was private because the subnet said so.

The CLI Evan types when the join is one VM and one subnet:

gcloud compute networks subnets describe private --region=us-central1 --format='get(privateIpGoogleAccess)'
gcloud compute instances describe app --zone=us-central1-a --format='yaml(networkInterfaces)'

The first call is the latch. The second call is the door. accessConfigs present with a natIP means the public door is open. Empty accessConfigs plus privateIpGoogleAccess: True is the coin. An output that prints the subnet name has not made that join.

Fact three. Private Google Access is not Private Service Connect, and it is not Serverless VPC Access.

The PCA notes keep three paragraphs on purpose (Private Service Stuffs). Private Google Access is the option used when GCP resources do not have external IP addresses and need Google APIs and services through the VPC default internet gateway, for the services that explicitly support it. Private Service Connect puts an endpoint in the VPC and you talk to Google APIs and published services through that endpoint. Serverless VPC Access is the connector Cloud Run, Cloud Functions, and App Engine use to reach into the VPC.

A stem that says "the Cloud Run service must reach Memorystore" is Serverless VPC Access. A stem that says "the VM has no external IP and must read a bucket" is PGA, or PSC if the stem names an endpoint. A stem that says "on-prem through VPN must reach BigQuery without a public IP on the destination" is still PGA for the services that support it, and PSC when they do not. The notes say that last split in one breath. Do not merge the three objects because they all contain the word private.

The Digital Leader table maps PGA to VPC Endpoints on the AWS side. That mapping is a study aid. It is not an identity. AWS VPC Endpoints are closer to PSC in shape. PGA is a subnet boolean. If a stem hands you google_compute_subnetwork.private_ip_google_access and you start authoring a PSC forwarding rule, you have left the shirt.

Fact four. Proof is the instance NIC plus the subnet flag, not a curl from the laptop.

The laptop is not in the VPC. curl https://storage.googleapis.com from the test runner proves the runner has internet. It does not prove PGA. It does not prove the VM lacks a public IP. 08-21 already taught you that an output can be last-apply. private_ip_google_access in an output is last-apply of the subnet. It is not last-apply of the NIC. The NIC can change in the same root and the subnet output will still print true.

Brikman, Conditionals with the count Parameter (Ch.5, pp.259-261), is the leftover if-resource pattern. count = var.enable_autoscaling ? 1 : 0 creates the object or creates nothing. Today's optional public door is the same altitude: a dynamic "access_config" with for_each = var.enable_public_ip ? [1] : [], or a reserved google_compute_address with count = var.enable_public_ip ? 1 : 0. The Cert lesson owns the one() / try() read when count is zero. This Ops lesson owns the meaning of zero: zero public addresses, PGA true, Google APIs reachable from the guest.

§III — Mechanism: the join the plugin will not make for you

The google plugin will apply a subnet with PGA true and an instance with access_config {} in one graph. No error. No warning. The two resources do not contradict at plan time. They contradict at the story you told yourself.

Name the four illegal joins.

Join one. PGA true, public IP present. The VM is on the internet. SSH from the laptop works. The subnet flag is true and idle. A dashboard that greens on private_ip_google_access has lied.

Join two. PGA false, public IP absent. The VM cannot reach Google APIs. gsutil hangs or fails. The instance looks private in the NIC dump. Privacy without the latch is a black hole for anything that talks to GCS, BigQuery, or Artifact Registry.

Join three. PGA true, public IP absent, you curl from the laptop and call it a test. The laptop was never the subject. Dev will refuse that HTTP on purpose.

Join four. You authored a PSC endpoint because the Digital Leader table said VPC Endpoints. PSC is a different resource family. It is valid. It is not this flag.

The legal join is narrow. Subnet private_ip_google_access = true. Instance network_interface names that subnet and omits access_config. Describe shows no natIP. Google API calls from the guest succeed. Public internet calls from the guest fail. That failure is the feature.

A module that takes both enable_pga and enable_public_ip must not treat them as synonyms. Brikman's count trick (pp.259-261) lets you create the reserved address only when enable_public_ip is true. It does not stop you from setting both true. A precondition on the instance can refuse the pair if the module's contract is "private VM." Lab 18 on the Cert slot will teach the read of the optional object. The refuse lives here if you want it.

lifecycle {
  precondition {
    condition     = !var.enable_public_ip
    error_message = "This module's contract is a VM with no public IP."
  }
}

That precondition is optional discipline, not GCP. GCP will happily mint both doors. The plugin is not the reviewer.

§IV — Worked Example: one VM, one subnet, two describes

A root that means the coin:

variable "enable_public_ip" {
  type    = bool
  default = false
}

resource "google_compute_instance" "app" {
  name         = "app"
  machine_type = "e2-micro"
  zone         = "us-central1-a"

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-12"
    }
  }

  network_interface {
    subnetwork = google_compute_subnetwork.private.id

    dynamic "access_config" {
      for_each = var.enable_public_ip ? [1] : []
      content {}
    }
  }
}

output "pga" {
  value = google_compute_subnetwork.private.private_ip_google_access
}

output "instance_name" {
  value = google_compute_instance.app.name
}

Apply with the default. pga prints true. instance_name prints app. Neither string is the NIC. Run the two gcloud describes. privateIpGoogleAccess is True. accessConfigs is empty. That pair is the only proof.

Now apply with -var enable_public_ip=true. The same two outputs print the same two strings. The NIC grows a natIP. The subnet did not change. 08-21's lesson was an Azure endpoint that survived the grant flipping. Today's lesson is a pair of Terraform outputs that survive the public door opening. Believe the NIC.

A reserved address does not change the coin. google_compute_address with address_type = "EXTERNAL" plus access_config { nat_ip = google_compute_address.app.address } is still a public door. Static versus ephemeral is addressing hygiene. It is not PGA.

Do not SSH to prove PGA. SSH to a VM with no public IP needs IAP or a bastion. Those are other shirts. The describes are enough for this fire.

§V — Connection to Prior Lessons

08-27 taught a visa the aws plugin must request. PGA is not a visa. It is a path. Do not rebuild assume_role on a subnet.

08-24 taught a nest the google plugin had to answer for Cloud SQL. private_ip_google_access is a boolean on google_compute_subnetwork, not a nest inside google_sql_database_instance.settings. Cloud SQL private IP and PGA are easy to mash because both say private. Cloud SQL private IP is private services access to the instance. PGA is VMs without public IPs reaching Google APIs. Keep the Cloud SQL shirt in 08-24.

08-21 taught that terraform output can still be last-apply. Today's output.pga is last-apply of the subnet. Use it as a pointer at which subnet to describe, not as the verdict.

08-18 taught the S3 backend and the DynamoDB lock. That cluster can sit under this root. The backend identity is a different question.

08-15 taught the GSA/KSA binding that is computed. Workload Identity answers who the Pod is when it calls Google APIs. PGA answers whether a VM without a public IP can call those APIs at all. A GKE node that has no public IP still needs a path. That path can be PGA on the node subnet. Do not reopen the annotation and the IAM member. The leftover is the node subnet flag, and it is not today's VM shirt either. Mention and leave it.

08-12 taught HTTP through an ALB during apply. HTTP from the laptop is the wrong instrument here. Dev will refuse it.

08-03 taught GitHub Actions OIDC. Leave it.

§VI — Connection to Today's Dev Lesson

The Dev slot is terratest. tf_day_dev_counter reads 11, 11 mod 3 is 2. Fourth terratest fire. 07-31 built the test. 08-12 watched during an apply. 08-21 asked Azure GetProperties after apply. Today's unused claim is test_structure.CopyTerraformFolderToTemp so parallel tests do not share a .terraform directory, then a GCP read that the instance has no public IP.

This Ops lesson is the module that fire copies. If the Dev lesson HTTP-gets storage.googleapis.com from the runner, it has tested the laptop. If this Ops lesson only restates VPC vocabulary, it has skipped the NIC. The hinge is one sentence. Terraform writes a subnet flag and omits access_config. Go copies the folder, applies the copy, and asks GCP whether a natIP exists. Empty is the pass.

§VII — Closing

access_config {} is a public door. private_ip_google_access = true is a Google-API door for VMs that have no public address. They are not synonyms. Private Service Connect is a third door. Serverless VPC Access is a fourth. The plugin will apply the illegal joins without comment. The two describes are the review.

Name it when you see it. The access that is not a public IP. Write the subnet flag. Leave the nest off the NIC. Print the names if you must. Believe the natIP field.

Examine well. The output will still be pretty. The latch is on the subnet. The door is on the instance. The guest is the one who walks through later.

Related