Our earlier piece on private network architecture for Gemini Enterprise covered the concept: Vertex AI Agent Engine runs your agent in a Google-managed tenant project with no default access to your VPC, and reaching a private resource from that agent requires a Private Service Connect (PSC) interface. This post is the part we didn’t cover there — the actual, step-by-step mechanics of setting one up, using the network model most enterprises are actually running: Shared VPC, with a central host project owning the network and the agent living in its own service project.
The one thing to get right before you start
The most common misconception about this setup: assuming you need a proxy in the middle. You don’t — not for reaching an on-premises resource.
Google’s own guidance is specific about this: a PSC interface can route directly to VPC or on-premises destinations within RFC 1918 address space (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). A proxy is only required for two different scenarios, covered later in this post — reaching the public internet when your project sits inside a VPC Service Controls perimeter, or reaching a non-RFC-1918 address. An on-premises Oracle database with a normal private IP is neither of those. It’s reachable directly.
Architecture — Shared VPC model
flowchart LR
subgraph Tenant["Google-managed tenant project"]
Agent[AI Agent<br/>Vertex AI Agent Engine]
end
subgraph SVC["Service project: ai-platform-prod"]
NA[Network Attachment<br/>references host subnet]
end
subgraph HOST["Host project: network-host"]
Subnet[psc-interface-subnet /28]
CR[Cloud Router]
end
OnPrem[(On-Premises<br/>Oracle Database)]
Agent -->|producer-initiated| NA
NA -.uses subnet from.-> Subnet
Subnet --> CR
CR -->|existing Interconnect / HA VPN| OnPrem
Two things distinguish this from a single-project setup. First, the network attachment’s subnet lives in the host project, since that’s where the Shared VPC and its connectivity to on-premises already live. Second, the network attachment resource itself is recommended to live in the service project alongside the agent — it simplifies which project’s IAM you’re managing day to day, and it’s the pattern Google’s own documentation recommends for exactly this reason.
Prerequisites
- A Shared VPC with an existing host project already connected to on-premises (Cloud Interconnect or HA VPN, with Cloud Router advertising your on-premises routes)
- A service project attached to that host project, where the agent will be deployed
- The on-premises Oracle database’s internal IP address (or hostname, once DNS is wired up) and listener port (1521 by default)
- These APIs enabled in the service project:
compute.googleapis.com,aiplatform.googleapis.com,dns.googleapis.com - Permissions to create network resources in the host project and modify IAM bindings in both projects
For the worked example, the Oracle database sits on-premises at 10.50.0.10:1521, service name INVPROD, reachable as inventory-db.corp.internal once DNS is wired up — reachable from the VPC today only because Interconnect/Cloud Router is already in place.
Step 1 — Create the network attachment subnet (host project) and network attachment (service project)
The network attachment needs a dedicated subnet — minimum /28 — in the host project, in the same region as your agent deployment. This subnet doesn’t host any VMs; it’s purely the address pool the PSC interface draws from.
hostproject=YOUR_HOST_PROJECT_ID
serviceproject=YOUR_SERVICE_PROJECT_ID
region=us-central1
# Run against the host project
gcloud compute networks subnets create psc-interface-subnet \
--project=$hostproject \
--network=YOUR_SHARED_VPC_NAME \
--region=$region \
--range=192.168.10.0/28
Now create the network attachment itself in the service project — the recommended placement, per Google’s Shared VPC guidance for PSC interfaces:
# Run against the service project, referencing the host project's subnet
gcloud compute network-attachments create agent-network-attachment \
--project=$serviceproject \
--region=$region \
--connection-preference=ACCEPT_AUTOMATIC \
--subnets=projects/$hostproject/regions/$region/subnetworks/psc-interface-subnet
ACCEPT_AUTOMATIC auto-accepts the connection from Vertex AI’s PSC interface without a manual approval step — the right choice here, since you’re the one provisioning both sides.
Step 2 — Grant the Vertex AI service agent the roles it needs
This is where the Shared VPC model adds a real, easy-to-miss step compared to a single-project setup: the service agent needs permission on two different projects, not one.
projectnumber=$(gcloud projects describe $serviceproject --format='value(projectNumber)')
# Create the service agent if this is the first Vertex AI resource in the service project
gcloud beta services identity create --service=aiplatform.googleapis.com --project=$serviceproject
# On the HOST project: permission to use the shared network
gcloud projects add-iam-policy-binding $hostproject \
--member="serviceAccount:service-${projectnumber}@gcp-sa-aiplatform.iam.gserviceaccount.com" \
--role="roles/compute.networkUser"
# On the SERVICE project (where the network attachment lives): permission to accept the PSC connection
gcloud projects add-iam-policy-binding $serviceproject \
--member="serviceAccount:service-${projectnumber}@gcp-sa-aiplatform.iam.gserviceaccount.com" \
--role="roles/compute.networkAdmin"
# DNS peering, wherever the peering target VPC/zone lives (the host project, in this setup)
gcloud projects add-iam-policy-binding $hostproject \
--member="serviceAccount:service-${projectnumber}@gcp-sa-aiplatform.iam.gserviceaccount.com" \
--role="roles/dns.peer"
If roles/compute.networkAdmin is broader than your organization allows for a service agent, a custom role scoped to exactly compute.networkAttachments.get, compute.networkAttachments.update, and compute.regionOperations.get covers what’s actually needed.
Step 3 — Allow the network attachment’s subnet to reach on-premises
This firewall rule belongs in the host project, since that’s where the Shared VPC and its route to on-premises live.
gcloud compute firewall-rules create allow-agent-to-onprem \
--project=$hostproject \
--network=YOUR_SHARED_VPC_NAME \
--direction=EGRESS \
--action=ALLOW \
--priority=1000 \
--destination-ranges=10.50.0.0/24 \
--rules=tcp:1521 \
--enable-logging
Scope the destination range and port to exactly what the agent needs — here, just Oracle’s default listener port to the specific /24 the database lives in, not a blanket allow to all of on-premises.
Step 4 — Set up name resolution
gcloud dns managed-zones create corp-internal-zone \
--project=$hostproject \
--dns-name="corp.internal." \
--visibility=private \
--networks=YOUR_SHARED_VPC_NAME
gcloud dns record-sets create inventory-db.corp.internal. \
--project=$hostproject \
--zone=corp-internal-zone \
--type=A \
--ttl=300 \
--rrdatas=10.50.0.10
If your host project already has a Cloud DNS forwarding zone pointed at your real on-premises DNS servers, skip creating a duplicate static zone — DNS peering in the next step follows the host project’s existing resolution chain, forwarding zone included.
Step 5 — Deploy the agent with the PSC interface configured
import json
import google.auth
import google.auth.transport.requests
import requests
credentials, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
credentials.refresh(google.auth.transport.requests.Request())
access_token = credentials.token
NETWORK_ATTACHMENT = f"projects/{SERVICE_PROJECT_ID}/regions/{REGION}/networkAttachments/agent-network-attachment"
response = requests.post(
f"{ENDPOINT}/v1beta1/projects/{SERVICE_PROJECT_ID}/locations/{REGION}/reasoningEngines",
headers={
"Content-Type": "application/json; charset=utf-8",
"Authorization": f"Bearer {access_token}",
},
data=json.dumps({
"displayName": "inventory-agent",
"spec": {
"packageSpec": {
"pickleObjectGcsUri": f"gs://{BUCKET}/{GCS_DIR}/inventory_agent.pkl",
"requirementsGcsUri": f"gs://{BUCKET}/{GCS_DIR}/requirements.txt",
"pythonVersion": "3.10",
},
"deploymentSpec": {
"pscInterfaceConfig": {
"networkAttachment": NETWORK_ATTACHMENT,
"dnsPeeringConfigs": [
{
"domain": "corp.internal.",
"targetProject": HOST_PROJECT_ID,
"targetNetwork": "YOUR_SHARED_VPC_NAME",
}
],
}
},
},
}),
)
reasoning_engine_id = json.loads(response.content)["name"].split("/")[5]
print(reasoning_engine_id)
Note dnsPeeringConfigs.targetProject points at the host project — DNS peering follows wherever the actual VPC and its DNS zones live, not necessarily the same project as the network attachment.
Step 6 — The agent code itself
Using python-oracledb in its default “thin” mode, which connects directly without requiring Oracle Instant Client to be installed:
import oracledb
def get_inventory_level(sku: str) -> dict:
"""Looks up current inventory level for a SKU from the on-premises Oracle database.
Args:
sku: The product SKU to look up.
Returns:
dict with sku, quantity_on_hand, and warehouse_location.
"""
connection = oracledb.connect(
user="agent_readonly",
password=get_db_password_from_secret_manager(), # never hardcode this
dsn="inventory-db.corp.internal:1521/INVPROD",
)
try:
with connection.cursor() as cursor:
cursor.execute(
"SELECT quantity_on_hand, warehouse_location FROM stock WHERE sku = :sku",
sku=sku,
)
row = cursor.fetchone()
if row is None:
return {"sku": sku, "error": "not found"}
return {"sku": sku, "quantity_on_hand": row[0], "warehouse_location": row[1]}
finally:
connection.close()
The database credential comes from Secret Manager, not an environment variable or config file baked into the deployment package — and the account (agent_readonly) is scoped to read-only access on exactly the table the agent needs, not a shared application credential with broader access.
Validating the connection
response = requests.post(
f"{ENDPOINT}/v1beta1/projects/{SERVICE_PROJECT_ID}/locations/{REGION}/reasoningEngines/{reasoning_engine_id}:query",
headers={"Authorization": f"Bearer {access_token}"},
data=json.dumps({"input": {"input": "What's the inventory level for SKU ABC-123?"}}),
)
print(response.text)
If it times out rather than returning an error, that’s usually a routing or firewall problem in the host project, not a DNS or credentials one — check that the network attachment’s subnet range is actually included in whatever firewall rule governs egress to your on-premises range first.
Reaching the public internet under VPC Service Controls: a Cloud Run–based proxy
Everything above assumes an RFC 1918 on-premises destination, which never needs a proxy. This section covers the genuinely different scenario: the agent’s project is inside a VPC Service Controls perimeter, and the agent needs to reach something on the public internet — a SaaS API, a public data source. Under VPC-SC, the agent’s default internet path (which normally bypasses your VPC entirely, going straight from the Google-managed tenant project to the internet) is blocked, specifically to prevent that path from becoming an unmonitored data-exfiltration route. The fix is the same shape as the on-prem case — route the traffic through your VPC via the PSC interface — but now something inside your VPC has to actually make the outbound call on the agent’s behalf.
We’re using Cloud Run for that instead of a VM, mainly to remove the OS from the list of things to patch and monitor. The tradeoff: Cloud Run has no fixed internal IP of its own, so making it reachable from your VPC (which the PSC interface traffic requires) needs a few more pieces than a VM would — a serverless NEG and an internal Application Load Balancer in front of it.
flowchart LR
subgraph Tenant["Google-managed tenant project"]
Agent[AI Agent]
end
subgraph SVC["Service project"]
NA[Network Attachment]
end
subgraph HOST["Host project — Shared VPC"]
ProxySubnet[Proxy-only subnet]
ILB[Internal Application<br/>Load Balancer]
NEG[Serverless NEG]
end
CloudRun[Cloud Run<br/>proxy service<br/>ingress: internal]
Internet((Public internet<br/>e.g. https://mekas.com))
Agent -->|PSC interface| NA
NA --> ILB
ILB --> ProxySubnet
ILB --> NEG
NEG --> CloudRun
CloudRun -->|default egress, no NAT needed| Internet
One genuine simplification versus the VM approach: Cloud Run has outbound internet access by default, so there’s no Cloud Router/Cloud NAT to configure for the proxy’s own egress — only for accepting inbound traffic from the VPC.
Deploy the Cloud Run proxy
Rather than a general-purpose forward proxy (which doesn’t map cleanly onto Cloud Run’s request/response model — there’s no straightforward way to do HTTP CONNECT tunneling), this is a small, purpose-built service that forwards to one specific external API and returns the result:
# main.py
from fastapi import FastAPI
import requests
app = FastAPI()
@app.get("/check-mekas-site")
def check_mekas_site():
response = requests.get("https://mekas.com", timeout=5)
return {"status_code": response.status_code, "content_length": len(response.content)}
gcloud run deploy agent-proxy \
--project=$serviceproject \
--source=. \
--region=$region \
--ingress=internal \
--allow-unauthenticated
--ingress=internal restricts reachability to internal traffic only (which includes traffic from the internal load balancer we’re about to create) — it isn’t reachable from the public internet at all, regardless of the --allow-unauthenticated flag, which just skips Cloud Run’s own IAM-based invoker check for that internal traffic.
Make it reachable from the VPC
# A dedicated subnet Google's regional load balancers use internally — one per region, reusable
# across other internal load balancers you build later.
gcloud compute networks subnets create proxy-only-subnet \
--project=$hostproject \
--purpose=REGIONAL_MANAGED_PROXY \
--role=ACTIVE \
--region=$region \
--network=YOUR_SHARED_VPC_NAME \
--range=10.129.0.0/23
gcloud compute network-endpoint-groups create agent-proxy-neg \
--project=$serviceproject \
--region=$region \
--network-endpoint-type=serverless \
--cloud-run-service=agent-proxy
gcloud compute backend-services create agent-proxy-backend \
--project=$serviceproject \
--region=$region \
--load-balancing-scheme=INTERNAL_MANAGED \
--protocol=HTTP
gcloud compute backend-services add-backend agent-proxy-backend \
--project=$serviceproject \
--region=$region \
--network-endpoint-group=agent-proxy-neg \
--network-endpoint-group-region=$region
The remaining pieces complete the load balancer: a regional SSL certificate (internal Application Load Balancers use a Compute Engine SSL certificate resource, not a Google-managed one), a regional URL map, a regional target HTTPS proxy, and the forwarding rule that actually allocates the internal IP:port the network attachment’s traffic connects to.
# Self-managed certificate — swap in your own cert/key, or an internal CA-issued pair
gcloud compute regional-ssl-certificates create ilb-ssl-cert \
--project=$serviceproject \
--region=$region \
--certificate=cert.pem \
--private-key=key.pem
gcloud compute region-url-maps create agent-proxy-urlmap \
--project=$serviceproject \
--region=$region \
--default-service=agent-proxy-backend
gcloud compute region-target-https-proxies create ilb-https-proxy \
--project=$serviceproject \
--region=$region \
--url-map=agent-proxy-urlmap \
--ssl-certificates=ilb-ssl-cert
gcloud compute forwarding-rules create ilb-https-forwarding-rule \
--project=$serviceproject \
--load-balancing-scheme=INTERNAL_MANAGED \
--region=$region \
--target-https-proxy=ilb-https-proxy \
--target-https-proxy-region=$region \
--network=YOUR_SHARED_VPC_NAME \
--subnet=private-subnet \
--ip-protocol=TCP \
--ports=443
--subnet here is the load balancer’s frontend subnet — an existing general-purpose subnet in the host project’s Shared VPC where the internal IP gets allocated from, not the proxy-only subnet created above (that one’s used automatically by the regional managed proxy layer and never referenced directly). The forwarding rule itself can be created from the service project even though the network and subnet live in the host project, same as everything else in this Shared VPC setup — that’s what roles/compute.networkUser on the host project is for.
Point the DNS record at the load balancer’s internal IP instead of a VM’s:
gcloud dns record-sets create agent-proxy.corp.internal. \
--project=$hostproject \
--zone=corp-internal-zone \
--type=A \
--ttl=300 \
--rrdatas=<ILB_INTERNAL_IP>
Calling it from the agent
Because this is a purpose-built endpoint rather than a transparent forward proxy, the agent’s tool code calls it directly — no proxies={} dict, no CONNECT tunneling:
def check_mekas_site() -> dict:
"""Checks the status of https://mekas.com via the internal proxy service, which
reaches the public internet on the agent's behalf.
"""
response = requests.get(
"https://agent-proxy.corp.internal/check-mekas-site",
timeout=5,
)
return response.json()
Common mistakes
- Standing up a proxy for on-premises traffic. If the destination is a normal RFC 1918 address, skip the entire proxy section above — it’s solving a problem you don’t have.
- Granting IAM roles in the wrong project. In Shared VPC,
compute.networkUsergoes on the host project;compute.networkAdmin(or the narrower custom role) goes wherever the network attachment actually lives. - Forgetting
roles/dns.peer.compute.networkAdminalone is enough for the PSC interface itself, but DNS peering silently fails without the DNS-specific role. - Deploying Cloud Run without
--ingress=internal. Without it, the service is also reachable from the public internet directly, defeating the point of routing through the VPC in the first place. - Hardcoding the on-premises IP. Works on day one, breaks the day someone re-IPs the database and nobody remembers this agent depends on it.
References
- Using Private Service Connect interface with Agent Runtime — Google Cloud Documentation
- Set up a Private Service Connect interface for Gemini Enterprise Agent Platform resources — Google Cloud Documentation
- Agent Engine PSC Explicit Proxy — Google Codelabs (source for the verified network attachment / DNS peering / IAM commands adapted throughout)
- Private Service Connect Interface Vertex AI Pipelines — Google Codelabs
- Set up a regional internal Application Load Balancer with Cloud Run — Google Cloud Documentation
- Serverless NEGs overview — Google Cloud Documentation

