CoursesGCP securityVPC firewall & private access

VPC firewall & private access

SA-targeted rules, hierarchical policies, no public IPs.

Advanced30 min · lesson 4 of 15

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.

firewall rules: tag-targeted vs identity-targeted
# 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 DISABLED
allow-lb-to-web prod-vpc INGRESS 1000 tcp:443 False
allow-app-to-db prod-vpc INGRESS 1000 tcp:5432 False
read the rule back with describe
gcloud compute firewall-rules describe allow-app-to-db --format=yaml
# Output:
allowed:
- IPProtocol: tcp
ports:
- '5432'
creationTimestamp: '2026-07-16T09:14:03.220-07:00'
direction: INGRESS
kind: compute#firewall
logConfig:
enable: false
name: allow-app-to-db
network: https://www.googleapis.com/compute/v1/projects/payments-prod/global/networks/prod-vpc
priority: 1000
sourceServiceAccounts:
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.

org-wide hierarchical policy, created and attached
# 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.

enable Private Google Access and prove it works
# 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].
True
gs://payments-prod-artifacts/app-1.4.2.tar.gz
gs://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.

prod-vpc: one guarded front door, everything else private
The internet side (watched)
External HTTPS load balancer
the only public front door; the VMs behind it stay private
Cloud IAP tunnel
admin SSH into no-external-IP VMs, no public bastion needed
prod-vpc (default-deny inbound)
web tier (tag: web-server)
accepts 443 only from LB health-check ranges
app tier (service account app@)
reaches the db by identity, never by IP
db tier (service account db@)
accepts 5432 only from the app@ service account
Reaching Google, privately
Private Google Access ON
no external IP; APIs travel Google's internal path
Storage / Logging / Artifact Registry
storage.googleapis.com never touches the public internet
Guardrails from above
Org hierarchical policy
deny SSH/RDP from 0.0.0.0/0; no project can switch it off
Public exposure is opt-in here: one guarded front door, everything else private, identity on the internal locks, and an organization rule the teams below cannot undo.
The subnet flag on its own does not turn Private Google Access on
Private Google Access is three things that all have to line up, and the flag is only one of them. The subnet needs the flag set. The VM needs a route pointing toward Google's IP ranges, and the default 0.0.0.0/0 route to the internet gateway is what actually carries that traffic, even on a VM with no external address. And DNS has to resolve storage.googleapis.com to an address that route covers. Delete the default route to 'harden' the network, or point googleapis.com at the restricted API range 199.36.153.4/30 without adding a route to match, and every API call quietly times out. The VM looks perfectly healthy, the logs show nothing interesting, and you will lose an afternoon before you think to read the routing table.

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.

terminal
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-baseline
gcloud 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"
output
NAME DIRECTION PRIORITY SRC ALLOW DENY
allow-health-checks INGRESS 1000 35.191.0.0/16,130.211.0.0/22 tcp:443
allow-iap-ssh INGRESS 500 35.235.240.0/20 tcp:22
deny-ssh-from-internet INGRESS 1000 0.0.0.0/0 tcp:22
action: deny
direction: INGRESS
enableLogging: true
kind: compute#firewallPolicyRule
match:
layer4Configs:
- ipProtocol: tcp
ports:
- '22'
- ipProtocol: tcp
ports:
- '3389'
srcIpRanges:
- 0.0.0.0/0
priority: 1000
privateIpGoogleAccess: true
privateIpv6GoogleAccess: DISABLE_GOOGLE_ACCESS
networkIP: 10.10.2.14
accessConfigs: [] # no external IP
gs://payments-prod-artifacts/app-1.4.2.tar.gz
gs://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.

Quick check
01A new project lands on your desk with VMs already running on the auto-created 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?
Incorrect — The two implied rules run the other way around: allow everything outbound, deny everything inbound. Inbound tcp:22 is covered, it just loses to a rule sitting at a lower number.
Correct — A VPC you build yourself starts closed inbound, with the implied deny parked at 65535. The default network is the exception, and default-allow-ssh and default-allow-rdp are the first things to check in a new project.
Incorrect — Nothing skips the firewall. To the VPC tcp:22 is an ordinary port, which is why one allow at a low enough number is all it takes to let the scanners knock.
Incorrect — A project with nothing above it still enforces its own VPC rules, and those start closed to inbound. This network is open because of a rule Google wrote, not because a parent is missing.
02A subnet describe returns privateIpGoogleAccess: true, and 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?
Incorrect — Private Google Access exists precisely so a VM with accessConfigs: [] reaches Google APIs over Google's internal network. Handing it a public address fixes the symptom by putting the door back onto the street.
Incorrect — That field governs the IPv6 side, and DISABLE_GOOGLE_ACCESS is what the describe output shows on a subnet that works. The IPv4 flag you already turned on is what carries this traffic.
Correct — Private Google Access is three things lining up: the subnet flag, a route pointing at Google's ranges, and DNS resolving to an address that route covers. The default route carries the traffic even on a VM with no external address.
Incorrect — One of the two implied rules allows all outbound traffic at 65535, so egress on tcp:443 is already permitted unless somebody wrote a deny above it. A missing allow is not what stalls these calls.
03Two weeks after an incident you need to know which machines opened connections to the database and when. Traffic clearly flowed through allow-app-to-db, but Cloud Logging has nothing to show, and describe on the rule returns a logConfig block reading enable: false. What gets you those records?
Correct — Logging is a per rule switch and it ships off, which is what enable: false in that describe output is telling you. Turn it on for the rules standing in front of data, while nothing is on fire.
Incorrect — Priority settles which rule wins when several of them match the same packet. It has no say in whether that match gets written down anywhere.
Incorrect — Both target types produce the same records once logging is on, and moving a database rule from an identity to a label anyone with VM edit rights can attach makes it weaker, not clearer.
Incorrect — Rules in a hierarchical policy are no chattier by default. The two org-baseline rules in this lesson only write records because --enable-logging was passed when each of them was created.

Related