Postgres on Google Cloud / Lesson 8 of 8
Taking away the public IP is one line of configuration and one genuinely hard problem. The hard problem is you.
Everything so far has assumed the instance has a public IP address. That is not as alarming as it sounds — lesson 2 covered why: the allowlist is empty, there is no password, and access is granted to an identity. The public endpoint is already identity-gated.
But once your workloads also run inside Google Cloud, the public IP has no remaining job. Removing it deletes the last internet-facing surface and the cross-cloud network hop at the same time. This lesson is what that costs.
Private IP is one line on the instance and one line in your application. The work is not there. It is in the network you have to build first, and in the developer access you have to replace before you switch it off — because the moment the public IP goes, every laptop in the team loses its connection.
A VPC network is your own private network inside Google Cloud. It spans every region, and you carve subnets out of it, one per region, each with a range of internal IP addresses. A VM you create gets an address from a subnet. Two VMs on the same VPC can talk over internal addresses without touching the internet.
Your managed PostgreSQL instance is not a VM you own. It runs in a network Google operates. So "put the database on my VPC" cannot mean what it sounds like — and the mechanism that bridges the gap is the part worth understanding.
The classic mechanism is private services access, usually written PSA. You reserve a block of addresses in your VPC, hand it to Google, and Google peers its network to yours and puts your instance's private address inside that block.
# 1. reserve the range — nothing uses it, it just gets set aside
gcloud compute addresses create google-managed-services \
--global --purpose=VPC_PEERING --prefix-length=16 \
--network=my-vpc
# 2. peer your VPC to Google's service producer network
gcloud services vpc-peerings connect \
--service=servicenetworking.googleapis.com \
--ranges=google-managed-services --network=my-vpc
| Decision | What the docs say |
|---|---|
| How big? | The minimum size is a singleTake the /16. It costs nothing. |
| Which addresses? | Select an allocated range that is completely separate from current and future subnet ranges— including anything reachable over peering. Future is the word that matters. |
| Can I change it later? | You can't modify an allocated range.You delete it and create a larger contiguous one, or bolt a second allocation onto the connection. |
An allocated range is immutable, and it has to avoid address space the organisation has not decided to use yet. Getting it wrong is cheap today and expensive after go-live. Build the network before you need it — a reserved range and an idle peering are both free, and doing it later converts a one-line change into a network change against a live production database.
PSA peers two whole networks together, which means one VPC per instance and a peering relationship to manage. Private Service Connect (PSC) instead publishes the instance behind a service attachment, and each consumer creates an endpoint — an internal address in their own subnet — pointing at it.
| Private services access | Private Service Connect | |
|---|---|---|
| Mechanism | VPC peering to Google's network | A service attachment plus per-consumer endpoints |
| Address space | A dedicated allocated range you cannot resize | Addresses from your ordinary subnets |
| Reach | One VPC network | Google's docs: connect from multiple Virtual Private Cloud (VPC) networks that belong to different groups, teams, projects, or organizations |
| Choose it when | One VPC, one team, and it is already built | Several projects or organisations need the database, or you would rather not manage a peering |
For a single team with one VPC, PSA is fine and is what most existing setups use. If you are choosing today and more than one project will connect, look at PSC first.
Three changes, and the first two are trivial:
# 1. the instance — this RESTARTS it
gcloud sql instances patch INSTANCE_NAME \
--network=projects/PROJECT_ID/global/networks/my-vpc \
--no-assign-ip
# 2. the application — one argument to the connector
from google.cloud.sql.connector import Connector, IPTypes
connector = Connector(ip_type=IPTypes.PRIVATE)
# getconn() and the engine are otherwise exactly as in lesson 5
Everything from lesson 4 survives this unchanged. The three layers are unaffected: IAM still decides who may connect, the IAM database user still has to exist, and the PostgreSQL grants still decide what you may do. Private IP is a network change, not an access-control change — and confusing the two is how people conclude they no longer need the grants.
From the docs: configuring an existing Cloud SQL instance to use private
IP causes the instance to restart, resulting in downtime.
Schedule it. And
do the network build in a separate, earlier change — PSA peering takes several
minutes to establish and you do not want that on the clock.
You have just removed the only route your team had. The proxy on a laptop
reaches the instance over its public IP. There is no public IP now. Every
developer, every GUI client, and every ad-hoc psql is locked out —
and so is anything that restores a dump or runs a manual migration from someone's
machine.
This is the single most-missed item in a private-IP migration. It is a real piece of work — not a footnote — and discovering it on the day means either a rollback or a team that cannot reach its database.
The standard answer is a small VM inside the VPC, with no external IP
at all, reached through IAP TCP forwarding. Google
describes it as letting you establish an encrypted tunnel over which you can
forward SSH, RDP, and other traffic to VM instances
— which means the bastion
needs no public address either.
# open the tunnel — leave it running
gcloud compute start-iap-tunnel BASTION_VM 5432 \
--local-host-port=localhost:5432 --zone=ZONE
# the firewall rule IAP needs; the range is fixed and documented
gcloud compute firewall-rules create allow-iap-ingress \
--network=my-vpc --direction=INGRESS --action=allow \
--rules=tcp:22,tcp:5432 --source-ranges=35.235.240.0/20
35.235.240.0/20 is not arbitrary — the documentation states it
contains all IP addresses that IAP uses for TCP forwarding
. And note what
this is not: it is not an allowlist of your team's home addresses. Access
is still granted by identity, through
roles/iap.tunnelResourceAccessor. The model from
lesson 2 holds all the way down.
| Option | Trade-off |
|---|---|
| Bastion VM plus IAP | Most control, and one more VM to patch and pay for. The usual choice. |
| Cloud Workstations | A managed development environment already inside the VPC. Nothing to maintain; a per-seat cost, and people have to work in it. |
| Run the proxy in the cluster | Fine for applications, useless for someone who needs a GUI client on a laptop. |
| Keep the public IP | Entirely defensible. The endpoint is identity-gated with an empty allowlist, which is why lesson 2 works. You are choosing to keep a small attack surface in exchange for not building any of this. |
IPTypes.PRIVATE
and confirm it connects over the private path.--no-assign-ip. Schedule the
restart.You run --no-assign-ip on Friday. What breaks that
nobody mentioned in the plan?
The proxy on a laptop reached the instance over the public IP. There is not one now. The three access layers are untouched — this is purely a network change — but the network it removed was the one your team used. Build the bastion and IAP tunnel first.
Why choose the allocated range before anything needs it?
You can't modify an allocated range.
Changing it means deleting and
recreating, against a live database if you left it late. A /16
is recommended but a /24 is permitted, and the reservation is
free.
After the switch, which of the three access layers changed?
None. Identity still decides who connects, the database user still has to exist, and the grants still decide what you may do. Private IP changes the route, not the permissions — which is also why it is not a substitute for any of them.
From memory: the two lines that perform the switch, and the one thing you must build before running either.
--no-assign-ip on the instance, and
ip_type=IPTypes.PRIVATE in the connector. Before either: the
replacement developer access — a no-external-IP bastion reached over an IAP
tunnel — tested while the public IP still works.