VPC firewall & private access
SA-targeted rules, hierarchical policies, no public IPs.
A virtual machine with a public IP address (Internet Protocol address, the number that identifies a machine on a network) is a door opening straight onto the street. Anyone walking past can knock, and the internet's scanners knock constantly, all night, forever. They find a fresh address within minutes of it existing. Network security on Google Cloud is the work of putting up the opposite kind of building. Most doors open only from the inside. The few that face the street have a guard, and that guard checks who you are rather than only which street you came from. The rules for all of it are set by the landlord, not by whichever tenant felt like propping a door open.
Each firewall rule is a door, and who you are beats where you came from
A VPC (Virtual Private Cloud, your own private network inside Google) arrives with a firewall already built in. Each rule is one instruction: allow or deny this kind of traffic, in this direction, to these machines. Two things make it smarter than the firewall on your laptop. It is stateful, so once you allow a request in, the reply is allowed back out on its own and you never write a return rule. And you choose which machines a rule covers in one of two ways. You can tag them, which works like a sticky label reading 'web-server'. Or you can point at the machine's service account, the identity it runs as, closer to a passport than a label. Tags are convenient. They are also weak. Letting someone attach the tag 'db-client' to their own VM looks like a tiny permission, the kind that slides through a review unnoticed, and now their box can talk to your database. Faking a service account is much harder, because changing the identity a VM runs as takes real privilege. So for anything standing in front of data, target by service account. One thing to keep straight while you do: matching on a service account here is still a network control. It decides which packets are allowed to arrive, not what the caller may do once they land. Permission to call an API is the job of IAM (Identity and Access Management, Google's permission system), and the two are enforced in completely different places.
# Web VMs accept 443 only from Google's global external load balancer. These two# ranges carry the health check probes AND the user traffic the proxy forwards on.# A regional load balancer differs: probes still come from these two ranges, but# user traffic arrives from that region's proxy-only subnet, so allow that range too.gcloud compute firewall-rules create allow-lb-to-web \--network=prod-vpc --direction=INGRESS --action=ALLOW \--rules=tcp:443 \--source-ranges=130.211.0.0/22,35.191.0.0/16 \--target-tags=web-server# The database accepts Postgres only from the app tier's IDENTITY, not an IP.gcloud compute firewall-rules create allow-app-to-db \--network=prod-vpc --direction=INGRESS --action=ALLOW \--rules=tcp:5432 \--source-service-accounts=app@payments-prod.iam.gserviceaccount.com \--target-service-accounts=db@payments-prod.iam.gserviceaccount.com# Both created:Creating firewall...done.NAME NETWORK DIRECTION PRIORITY ALLOW DENY DISABLEDallow-lb-to-web prod-vpc INGRESS 1000 tcp:443 Falseallow-app-to-db prod-vpc INGRESS 1000 tcp:5432 False
gcloud compute firewall-rules describe allow-app-to-db --format=yaml# Output:allowed:- IPProtocol: tcpports:- '5432'creationTimestamp: '2026-07-16T09:14:03.220-07:00'direction: INGRESSkind: compute#firewalllogConfig:enable: falsename: allow-app-to-dbnetwork: https://www.googleapis.com/compute/v1/projects/payments-prod/global/networks/prod-vpcpriority: 1000sourceServiceAccounts:targetServiceAccounts:
Two fields in that output repay a close look. The first is priority, sitting at 1000, and to read that number you need to know what it is competing against. Every VPC ships with two rules you never wrote: allow everything outbound, deny everything inbound, both parked at priority 65535, the weakest priority there is. Your own rules land above them at whatever number you pick, and the lower number wins. So a network you build yourself starts closed to inbound traffic, and your work is poking narrow, deliberate holes in it. The auto-created 'default' network is the exception, and it is the first thing to check in a new project: Google pre-populates it with real rules at priority 65534, among them default-allow-ssh and default-allow-rdp, both open to 0.0.0.0/0. Nobody on your team wrote those, they beat the implied deny, and they are the same hole this lesson is about. Delete or replace them before anything real runs there. The other field is logConfig, sitting at false. On the rules that guard something real, switch it on with --enable-logging and every allowed and denied connection lands in Cloud Logging. The first question in any incident is 'was this connection allowed, and by which rule'. You want that answer already written down, not reconstructed from memory at two in the morning.
A rule the teams below cannot switch off
Per-VPC rules live inside a single project, and whoever owns that project can rewrite them whenever they like. That works fine right up to the afternoon when one team, in one project, opens SSH (Secure Shell, the standard remote-login protocol for Linux machines) to the entire internet because a blog post told them to. What you want is a rule that sits above every project and cannot be edited from underneath. That is a hierarchical firewall policy. It is closer to a city's building code than to a lock a tenant screws onto their own door: it attaches to the organization or to a folder, every project below inherits it, and a project admin has no button that removes it. A sensible baseline reads 'deny inbound SSH and RDP (Remote Desktop Protocol, the Windows equivalent of SSH) from 0.0.0.0/0, everywhere, always', where 0.0.0.0/0 means every address on the internet. Ship that deny on its own, though, and you lock yourself out along with the attackers, because your own admin sessions also arrive from an address inside 0.0.0.0/0. So put the carve-out in first: one narrow allow, at a lower number so it wins, for 35.235.240.0/20. That is the range Google's Identity-Aware Proxy (IAP) sends admin logins from, and it is the door you keep for yourself.
# 1. Create an org-scoped policy.gcloud compute firewall-policies create \--organization=884303252152 --short-name=org-baseline \--description="Baseline guardrails inherited by every project"# 2. Allow admin logins from the IAP range FIRST, at a lower number, so the# deny in step 3 can never lock you out of your own organization.gcloud compute firewall-policies rules create 900 \--organization=884303252152 --firewall-policy=org-baseline \--direction=INGRESS --action=allow \--layer4-configs=tcp:22,tcp:3389 --src-ip-ranges=35.235.240.0/20 \--enable-logging# 3. Now deny remote-login ports from the whole internet, and log the hits.gcloud compute firewall-policies rules create 1000 \--organization=884303252152 --firewall-policy=org-baseline \--direction=INGRESS --action=deny \--layer4-configs=tcp:22,tcp:3389 --src-ip-ranges=0.0.0.0/0 \--enable-logging# 4. Attach it so the whole org inherits it.gcloud compute firewall-policies associations create \--organization=884303252152 --firewall-policy=org-baseline \--name=org-baseline-assoc# Output:Created [https://www.googleapis.com/compute/v1/locations/global/firewallPolicies/128193021340].Creating firewall policy rule...done.Creating firewall policy rule...done.Creating association...done.
So how does all that get settled when a packet actually arrives? Google walks the hierarchy from the top down. The organization policy is checked first, then any folder policy, then the VPC's own rules last. A deny high in the tree wins outright and the project below never gets a vote, which is exactly why a team cannot undo it by accident. When you do want a parent level to step aside, a goto_next action hands a chosen slice of traffic down to be decided lower. That is how the guardrail stays hard on the dangerous ports while each team still manages their own application ports in their own project.
No public address, and the VM still reaches Google's APIs
Here is the tension. You have decided your VMs get no external address, which is the right call. But the app on that machine still has to read from Cloud Storage, write its logs, and pull a container image from Artifact Registry. Those are all Google APIs (an API, or application programming interface, is the doorway one piece of software uses to talk to another), and they live at public hostnames like storage.googleapis.com. So how does a machine with no public address reach a public API? Private Google Access is the service corridor inside the building, the one the staff use so they never have to step onto the pavement. Turn it on for a subnet (one slice of your private network) and the VMs in that subnet reach Google's APIs across Google's own internal network, with no external address of their own. One switch, set per subnet.
# Turn on Private Google Access for the subnet holding the private VMs.gcloud compute networks subnets update prod-subnet-us \--region=us-central1 --enable-private-ip-google-access# Confirm the flag is on.gcloud compute networks subnets describe prod-subnet-us \--region=us-central1 --format="value(privateIpGoogleAccess)"# From a VM with NO external IP (reached over IAP), a Google API now responds.gcloud compute ssh app-vm --zone=us-central1-a --tunnel-through-iap \--command="gcloud storage ls gs://payments-prod-artifacts"# Output:Updated [https://www.googleapis.com/compute/v1/projects/payments-prod/regions/us-central1/subnetworks/prod-subnet-us].Truegs://payments-prod-artifacts/app-1.4.2.tar.gzgs://payments-prod-artifacts/app-1.4.3.tar.gz
That --tunnel-through-iap flag deserves a second look. IAP is how you get a shell on a machine with no public address at all. Your SSH session rides an authenticated tunnel through Google's front door instead of crossing the open internet, so you never need a bastion with a public address sitting there only to give admins a way in. You have now shaped who gets in, and how private VMs reach Google. What those VMs may send out to the rest of the internet, and how DNS (the Domain Name System, the internet's address book) decides where those connections even point, is a separate problem with its own controls. The next lesson takes it apart.
Targeting by tag or by service account beats keeping a list of addresses, because the identity travels with the workload and an address does not. When a MIG (managed instance group, the thing that replaces failed VMs for you) swaps a dead machine for a fresh one, the new instance carries the same identity and still matches the rule. Nothing to update by hand. At the folder or organization level, one hierarchical policy denies SSH from 0.0.0.0/0 for everybody at once, while a separate rule still lets SSH arrive from the IAP range for break-glass access, the emergency way in when the normal path is broken. Put that deny above any allow a well-meaning project Owner might add later.
A public address is not the price of patching a server or calling a Cloud API. Private Google Access, plus Cloud NAT for the cases where a machine genuinely has to reach something outside Google, plus IAP for admin logins, covers almost every app tier you will build. When your inventory still lists external addresses on a subnet, treat each one as a ticket with an expiry date on it rather than as the normal state of affairs.
Create these rules the same way you create the rest of your infrastructure: a reviewed change in version control, a pipeline that applies it, and a test that fails the build the moment someone re-adds an allow on tcp:22 from 0.0.0.0/0. A firewall rule clicked into the console during an outage tends to survive about a week past the audit. Write down which organization the org-baseline policy attaches to, which identity is allowed to run gcloud compute firewall-policies rules create, and the Cloud Logging query that shows the deny actually firing. Then hand the steps to someone who has never touched the network and see whether they get through them without asking you a single question.
Try this
In a lab VPC, list the firewall rules and read their priorities, check that the hierarchical policy is denying what you think it denies, and prove that a VM with no public address can still reach a Google API through Private Google Access.
gcloud compute firewall-rules list --project=payments-prod --format="table(name,direction,priority,sourceRanges.list():label=SRC,allowed[].map().firewall_rule().list():label=ALLOW,denied[].map().firewall_rule().list():label=DENY)"gcloud compute firewall-policies rules describe 1000 \--organization=884303252152 --firewall-policy=org-baselinegcloud compute networks subnets describe app-subnet \--region=europe-west1 --project=payments-prod \--format="yaml(privateIpGoogleAccess,privateIpv6GoogleAccess)"gcloud compute instances describe web-1 --zone=europe-west1-b \--format="yaml(networkInterfaces[0].networkIP,networkInterfaces[0].accessConfigs)"gcloud compute ssh web-1 --zone=europe-west1-b --tunnel-through-iap \--command="gcloud storage ls gs://payments-prod-artifacts"
NAME DIRECTION PRIORITY SRC ALLOW DENYallow-health-checks INGRESS 1000 35.191.0.0/16,130.211.0.0/22 tcp:443allow-iap-ssh INGRESS 500 35.235.240.0/20 tcp:22deny-ssh-from-internet INGRESS 1000 0.0.0.0/0 tcp:22action: denydirection: INGRESSenableLogging: truekind: compute#firewallPolicyRulematch:layer4Configs:- ipProtocol: tcpports:- '22'- ipProtocol: tcpports:- '3389'srcIpRanges:- 0.0.0.0/0priority: 1000privateIpGoogleAccess: trueprivateIpv6GoogleAccess: DISABLE_GOOGLE_ACCESSnetworkIP: 10.10.2.14accessConfigs: [] # no external IPgs://payments-prod-artifacts/app-1.4.2.tar.gzgs://payments-prod-artifacts/app-1.4.3.tar.gz
Takeaway
Point your firewall rules at identities rather than at broad address ranges, keep external addresses off any workload that does not truly need one, and leave Private Google Access switched on so private VMs can still call Google's APIs without ever walking onto the public internet.
Next you guard the way out: Cloud NAT, Secure Web Proxy or an egress allow-list, and DNS Firewall. Locking the front door counts for very little if a compromised process can dial home whenever it feels like it.
default network. gcloud compute firewall-rules list shows default-allow-ssh permitting tcp:22 from 0.0.0.0/0 at priority 65534, and nobody on your team wrote it. Why is that rule letting the internet in?describe web-1 shows networkIP: 10.10.2.14 with accessConfigs: [] and no external address. The app on that VM still hangs on every call to storage.googleapis.com until it times out. Where do you look?describe on the rule returns a logConfig block reading enable: false. What gets you those records?