Originally published in Google Cloud - Community.
I work across a lot of Google Cloud projects and accounts from a MacBook. Most of the VMs have no public IP, which is how it should be: Identity-Aware Proxy (IAP) lets you reach them through Google instead of through a bastion or a VPN.
On Windows, Google's answer is IAP Desktop, an open-source client with SSH and Remote Desktop tabs. There is no Mac version, and the GitHub issue asking for one sat open for years. On a Mac you get gcloud, a terminal, and a separate RDP app pointed at localhost.
This post covers how IAP TCP forwarding works, how to do everything with gcloud for free, where that gets painful, and the native client I ended up building.
How IAP TCP forwarding works

Your Mac never talks to the VM directly. Google's relay does, from a fixed range.
A tunnel is a WebSocket over TLS to tunnel.cloudproxy.app. Your Google account's token rides along, and the relay checks that the account has roles/iap.tunnelResourceAccessor (IAP-secured Tunnel User) on the project or the instance. If it does, the relay opens an ordinary TCP connection to the VM's internal address, from the range 35.235.240.0/20.
Two things follow from that:
- The VM needs nothing installed and no public IP. It only needs a firewall rule that lets
35.235.240.0/20in on the ports you use. - Every TCP connection is its own WebSocket. A "tunnel" on your laptop is a listener on
127.0.0.1that opens a new relay session for each socket it accepts. Ten SSH sessions through one tunnel are ten WebSockets.
The setup, once per project
The firewall rule (22 for SSH, 3389 for RDP):
gcloud compute firewall-rules create allow-iap-ingress \
--project=acme-prod --network=default \
--direction=INGRESS --action=allow \
--rules=tcp:22,tcp:3389 \
--source-ranges=35.235.240.0/20
The role for each person who connects:
gcloud projects add-iam-policy-binding acme-prod \
--member=user:alice@example.com \
--role=roles/iap.tunnelResourceAccessor
That is all IAP needs. The same setup serves IAP Desktop, gcloud and any other IAP client.
The free route on a Mac
SSH is one command:
gcloud compute ssh example-vm \
--project=acme-prod --zone=europe-west1-b \
--tunnel-through-iap
Files go through the same tunnel:
gcloud compute scp ./backup.tar.gz example-vm:~/ \
--tunnel-through-iap --zone=europe-west1-b --project=acme-prod
Remote Desktop to a Windows VM takes two steps. First a Windows password, then a tunnel that you leave running while an RDP client such as Microsoft's Windows App connects to localhost:33389:
gcloud compute reset-windows-password win-01 \
--zone=europe-west1-b --project=acme-prod --user=northwind
gcloud compute start-iap-tunnel win-01 3389 \
--local-host-port=localhost:33389 \
--zone=europe-west1-b --project=acme-prod
A database works the same way: start-iap-tunnel to port 5432, and your SQL client connects to the local port.
This all works, and it costs nothing. Step-by-step versions with screenshots are in the RDP guide and the firewall and IAM guide.
Where it got painful for me

Before: one terminal per tunnel. After: one window.
The commands are fine one at a time. My day looked different:
- Several accounts. gcloud has one active account per configuration, and
gcloud config set accountdoes not move Application Default Credentials, so client libraries keep using the old account. Two tunnels as two accounts means two gcloud processes. - A terminal tab per
start-iap-tunnel, and remembering which local port maps to which VM. - Remote Desktop in one app, SSH in another, files in a third.
- Sleep and Wi-Fi changes. The tunnels came back slowly or not at all after the lid had been closed.
None of this is a bug in gcloud. It is a CLI, and I wanted a window.
What I built
Ostgate is a native macOS app that opens IAP tunnels itself, without gcloud. You sign in with a Google account in your browser and it lists the projects and VMs that account can reach. Several accounts can be signed in side by side.

VMs from two accounts in one table.
Remote Desktop
Remote Desktop runs inside the window. Ostgate asks for or generates the Windows password through the guest agent, opens the tunnel and connects:

From the VM list to a Windows desktop, over IAP.

Clipboard sync, a Mac folder shared as a Windows drive, and a desktop that resizes with the window.
SSH and files
SSH opens in tabs, with keys generated in the Mac's Secure Enclave and published through OS Login or instance metadata. Appliances without OS Login can use a password or a key file of your own.
Every SSH instance also has a Files tab, an SFTP browser. You navigate, create folders, rename and delete, and drag files in from Finder and out to it:

Folders go up in one go; folders and several files come down as one ZIP.
The parts I wanted most from scp and never had:
- A whole folder uploads in one step, and a folder or a multi-file selection downloads as one ZIP. When an instance has no GNU tar or allows SFTP only, the files go over SFTP several at a time instead, and the app says why.
- Transfers run in the background with progress and Cancel. A cancelled or failed transfer never leaves half a file behind.
- A Files tab that loses its connection shows Reconnect and comes back in the same folder.

The Files tab on a VM with no public IP.
Tunnels and port forwarding
This is the part that replaced most of my terminal tabs. A tunnel maps a port on the VM to 127.0.0.1 on the Mac:

A PostgreSQL tunnel, from the VM's menu to a listening port.
- Any port, with presets for SSH, RDP, PostgreSQL, MySQL, SQL Server, Redis and HTTP(S). The local port stays the same between runs, so saved connections in a database client keep working.
- Copy Command puts the client's command line on the clipboard; web tunnels open in the browser.
- A tunnel can start with its account, keeps running when the window is closed, and sits in the menu bar. After a Wi-Fi drop or sleep it dials again as soon as the network is back.
- Ports below 1024 work. A forward can listen on 443 or 22 under a host name of your choice: each alias gets its own loopback address between 127.0.0.2 and 127.0.0.254, so two forwards can both use 443. A small helper that you allow once in System Settings binds those ports.
Some targets are not VMs at all. A forward profile carries several forwards over one SSH session to anything a VM can reach, such as an internal load balancer or a private database, and can add /etc/hosts aliases while it runs. Cloud SQL instances on a private IP open through one of your running VMs, with no separate proxy VM. Internal-only Cloud Run services and functions open in the browser through a local proxy.

Tunnels and forward profiles, each with the account it runs as.
Terminal still matters, so one click adds an SSH config block. After that, ssh, scp, rsync and an IDE's Remote-SSH reach a VM by a predictable name, even with Ostgate's window closed:
ssh example-vm.europe-west1-b.acme-prod.gcp
rsync -avP ./releases/ example-vm.europe-west1-b.acme-prod.gcp:~/releases/
Three things that were harder than they look
The protocol is not documented. Google documents how to use IAP, not what goes over the wire. The source of gcloud and of IAP Desktop is the specification: a binary framing with data chunks up to 16 KiB and acknowledgements that count bytes, plus a reconnect endpoint that resumes a session from the last acknowledged byte. Both clients agree on it, and Google can change it, so the client has to tolerate messages it does not know.
Waking up. After sleep, every SSH, RDP and database connection is its own WebSocket and has to come back on its own. My first retry loop never gave up on a dead network: a dial counted as successful before the WebSocket upgrade had finished, and the failure that came later restarted the loop with a fresh counter. A dial now means the upgrade completed, with a 15-second handshake deadline and backoff from 1.5 to 20 seconds.
The local port is a door. A listener on 127.0.0.1 accepts any process on the Mac, not only yours. Ostgate checks which process is on the other end of every connection before wiring it to IAP. By default only Ostgate itself gets through; you can allow your own processes, or any local process after a confirmation.
When something fails
IAP reports failures as WebSocket close codes. The three that cover most cases:
| Code | What it means | Usual fix |
|---|---|---|
| 4033 | The account lacks the IAP tunnel role | Grant roles/iap.tunnelResourceAccessor
|
| 4003 | IAP could not reach the port on the VM | Firewall rule for 35.235.240.0/20, VM running, service listening |
| 4047 | The instance or zone lookup failed | Check the name, zone and network interface |
The full table with gcloud's error messages is in IAP tunnel errors.
Where IAP Desktop still does more
IAP Desktop is free and open source, and it does things Ostgate does not: RDP redirection of microphones, printers and smart cards, joining a VM to Active Directory, certificate-based access, connections over VPN or Interconnect, and central policy through group policies. If you are on Windows, use it.
Try it
Ostgate runs on Apple Silicon Macs with macOS 15 or later. It has a 7-day trial with no sign-up, then it is paid. It does not route anything through a server of mine: the app talks to Google as you, tokens stay in the macOS Keychain, and there is no telemetry.
A side-by-side comparison with IAP Desktop is at ostgate.app/iap-desktop-mac.
I'm the developer of Ostgate. Not affiliated with Google. Drafted with AI assistance and edited by me.
Top comments (0)