Google Cloud · lesson 5 of 10
Also part of: Networking
GCP VPC Networking Basics
Build a custom VPC with a subnet and firewall rules, then debug the classic case of a VM with a public IP that still can't reach the internet.
By SkillByExample · Published
The default VPC hides most of the decisions this lesson is about — it ships with a subnet in every region and rules that already allow SSH, RDP, and internal traffic. Building a custom VPC from nothing forces every one of those decisions into the open, including the one that surprises the most people: a VM can have a public IP and still not reach the internet.
Creating a custom VPC and subnet
gcloud compute networks create lessons-vpc --subnet-mode=custom
gcloud compute networks subnets create lessons-subnet \
--network=lessons-vpc \
--region=us-central1 \
--range=10.10.0.0/24
Created [https://www.googleapis.com/compute/v1/projects/skillbyexample-437219/global/networks/lessons-vpc].
NAME MODE IPV4_RANGE GATEWAY_IPV4
lessons-vpc CUSTOM
Created [https://www.googleapis.com/compute/v1/projects/skillbyexample-437219/regions/us-central1/subnetworks/lessons-subnet].
NAME REGION NETWORK RANGE
lessons-subnet us-central1 lessons-vpc 10.10.0.0/24
--subnet-mode=custom is the important flag — without it, GCP auto-creates one subnet per
region immediately, which defeats the point of seeing each piece added deliberately.
A VM on the new network, with no rules yet
gcloud compute instances create app-1 \
--zone=us-central1-a \
--machine-type=e2-micro \
--network=lessons-vpc \
--subnet=lessons-subnet \
--image-family=debian-12 \
--image-project=debian-cloud
Created [https://www.googleapis.com/compute/v1/projects/skillbyexample-437219/zones/us-central1-a/instances/app-1].
NAME ZONE MACHINE_TYPE INTERNAL_IP EXTERNAL_IP STATUS
app-1 us-central1-a e2-micro 10.10.0.2 35.192.44.118 RUNNING
It has an external IP — 35.192.44.118 — already. That alone is not enough.
The debugging sequence: “why can’t my instance reach the internet”
gcloud compute ssh app-1 --zone=us-central1-a --command="curl -s -m 5 https://www.google.com -o /dev/null -w '%{http_code}\n'"
curl: (28) Connection timed out after 5001 milliseconds
Step one: check the firewall rules on this network.
gcloud compute firewall-rules list --filter="network:lessons-vpc"
NAME NETWORK DIRECTION PRIORITY ALLOW DENY DISABLED
Empty — same as a fresh custom VPC always starts. Unlike the default network, a custom-mode
VPC has no implied egress-allow rule, so even outbound HTTPS is blocked until a rule
explicitly permits it.
gcloud compute firewall-rules create lessons-allow-egress \
--network=lessons-vpc \
--direction=EGRESS \
--action=ALLOW \
--rules=tcp,udp \
--destination-ranges=0.0.0.0/0
Creating firewall...done.
NAME NETWORK DIRECTION PRIORITY ALLOW DENY DISABLED
lessons-allow-egress lessons-vpc EGRESS 1000 tcp,udp False
gcloud compute ssh app-1 --zone=us-central1-a --command="curl -s -m 5 https://www.google.com -o /dev/null -w '%{http_code}\n'"
200
The external IP was never the blocker — the missing egress rule was. This is the single most common “internet doesn’t work” ticket on a freshly built custom VPC.
Scoping ingress with a network tag instead of opening everything
gcloud compute instances add-tags app-1 --zone=us-central1-a --tags=web
gcloud compute firewall-rules create lessons-allow-web \
--network=lessons-vpc \
--direction=INGRESS \
--action=ALLOW \
--rules=tcp:80,tcp:443 \
--source-ranges=0.0.0.0/0 \
--target-tags=web
Updated [app-1].
Creating firewall...done.
NAME NETWORK DIRECTION PRIORITY ALLOW DENY DISABLED
lessons-allow-web lessons-vpc INGRESS 1000 tcp:80,tcp:443 False
This rule only affects instances tagged web on this VPC — other instances on the same
network, untagged, remain unreachable on 80/443 even though the rule exists.
Practice
1. Confirm the target-tags scoping by creating a second, untagged VM and testing port 80 against it.
gcloud compute instances create app-2 \
--zone=us-central1-a --machine-type=e2-micro \
--network=lessons-vpc --subnet=lessons-subnet \
--image-family=debian-12 --image-project=debian-cloud
curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://<app-2-external-ip>/
curl: (28) Connection timed out after 5001 milliseconds
Same VPC, same firewall rule set, but app-2 was never tagged web, so lessons-allow-web
never matches it — proof the rule is scoped by tag, not by network membership alone.
2. Add a DENY rule at a lower priority number than the ALLOW rule and see it win.
gcloud compute firewall-rules create lessons-deny-web \
--network=lessons-vpc \
--direction=INGRESS \
--action=DENY \
--rules=tcp:80 \
--priority=900 \
--target-tags=web
Creating firewall...done.
curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://35.192.44.118/
curl: (28) Connection timed out after 5001 milliseconds
Priority 900 beats priority 1000 (lower number = evaluated first, and it’s a DENY), so this
rule now overrides lessons-allow-web for the same traffic even though both target web.
3. Remove the external IP from app-1 and set up Cloud NAT so it can still reach the internet.
gcloud compute instances delete-access-config app-1 --zone=us-central1-a --access-config-name="External NAT"
gcloud compute routers create lessons-router --network=lessons-vpc --region=us-central1
gcloud compute routers nats create lessons-nat \
--router=lessons-router --region=us-central1 \
--nat-all-subnet-ip-ranges --auto-allocate-nat-external-ips
Updating instance...done.
Created [https://www.googleapis.com/compute/v1/projects/skillbyexample-437219/regions/us-central1/routers/lessons-router].
Creating NAT...done.
app-1 now has no public IP at all — nothing can initiate a connection to it — but outbound
calls still succeed through the NAT gateway’s shared external IP.
4. List all routes on the VPC to see the default internet route Cloud NAT relies on.
gcloud compute routes list --filter="network:lessons-vpc"
NAME NETWORK DEST_RANGE NEXT_HOP PRIORITY
default-route-a1b2c3d4e5f6g7h8i9 lessons-vpc 0.0.0.0/0 default-internet-gateway 1000
This system-generated route is what makes 0.0.0.0/0 traffic reach the internet gateway at all — firewall rules decide what’s allowed, but a route is what makes a destination reachable in the first place.
5. Clean up every resource created in this lesson.
gcloud compute instances delete app-1 app-2 --zone=us-central1-a --quiet
gcloud compute routers nats delete lessons-nat --router=lessons-router --region=us-central1 --quiet
gcloud compute routers delete lessons-router --region=us-central1 --quiet
gcloud compute firewall-rules delete lessons-allow-egress lessons-allow-web lessons-deny-web --quiet
gcloud compute networks subnets delete lessons-subnet --region=us-central1 --quiet
gcloud compute networks delete lessons-vpc --quiet
Deleted [app-1].
Deleted [app-2].
Deleted [lessons-nat].
Deleted [lessons-router].
Deleted [lessons-allow-egress].
Deleted [lessons-allow-web].
Deleted [lessons-deny-web].
Deleted [lessons-subnet].
Deleted [lessons-vpc].
A network can’t be deleted while any subnet, router, or instance still references it — this order (instances and routers first, then rules, then subnets, then the network) avoids the “resource in use” errors from doing it out of sequence.
Next: Cloud SQL — a managed Postgres instance, connecting to it, and a backup/restore cycle.