At Saras I was standing up a dev cluster on Azure - one VNet, a handful of VMs, the usual trading-platform services. SSH started out gated by single static "allow my IP" firewall rules, and home ISP IPs rotate, so the rules went stale and locked people out. I replaced that whole access plane: one tiny reverse bastion VM that runs only cloudflared and dials outbound to Cloudflare, WARP on each laptop, and Google SSO as the gate. Then I deleted every internet-facing inbound rule. The result: no open port 22 anywhere, rotating dev IPs stopped mattering, and access is per-person and revocable in one click. This post is the architecture, the packet-level flow, the split-tunnel routing trap that cost me an afternoon, and how the thing is run day to day.
The problem: rotating IPs and a wall of firewall rules
The cluster is boring on purpose. One Azure Virtual Network, 10.0.0.0/16, with a single subnet 10.0.1.0/24. Each service gets its own small VM with a private IP - infra on 10.0.1.4, master-api on 10.0.1.5, search on 10.0.1.6, and so on. A subnet-level Network Security Group (Azure's stateful firewall, an NSG) decides what can talk to what.
The first version of SSH access was the obvious one: an NSG rule that allowed port 22 from my home IP. It worked for exactly as long as my ISP kept that IP. Then the lease rotated, the rule pointed at an address I no longer had, and I was locked out of a box mid-deploy. The "fix" at the time was to add another rule for the new IP. Do that across multiple VMs, for multiple developers, on networks that all rotate, and you end up with a wall of single-IP allow rules that are simultaneously too open (every one of those IPs is a public door) and too brittle (any of them can go stale overnight).
The core issue is that an IP address is the wrong thing to authenticate. I do not care which coffee-shop IP a teammate is on. I care that it is them, and that I can cut their access the day they leave. IP allow-lists answer neither question. They authenticate a network location that the person does not control and that changes without warning.
So the requirements were:
- Multiple developers, each with their own access I can grant and revoke individually.
- Port 22 (and everything else) not reachable from the public internet.
- Resilient to rotating client IPs - location should be irrelevant.
- As little as possible installed on the service VMs themselves.
The options I weighed (and dropped)
| Option | Why I passed |
|---|---|
| Keep port 22 open + SSH keys | Still a public door on every VM. Constant background brute-force noise, and no clean per-person revocation - pulling one authorized_keys entry across N boxes is a chore, and the port is still exposed. |
| A VPN appliance / agent on every VM | Software to install, patch, and monitor on each service box. I wanted the opposite - nothing extra on the app VMs. |
| Tailscale | Genuinely good, and its subnet-router mode is basically the same shape as what I built (one node advertises the subnet, the rest stay untouched). It was a real contender. |
| A classic public bastion / jump host | The traditional answer, but it still means one public port 22 on the bastion, still an IP allow-list in front of it, and a two-hop ProxyJump for every connection. |
The decision came down to two things. First, identity over IP: whatever I picked had to authenticate the person, hook into our Google Workspace, and revoke per-person. Second, footprint: nothing on the service VMs, and ideally nothing with a public listening port at all.
I went with Cloudflare Tunnel + WARP. Tailscale would have worked too - the deciding factor was that Saras was already in the Cloudflare ecosystem, WARP gave me identity-based device enrollment against Google SSO for free (up to 50 users), and the same Cloudflare layer is what will later front the public app ingress. One vendor, one identity model, one tunnel that dials outward so there is no public listener to attack.
Architecture: a reverse bastion
The word "bastion" usually means a hardened jump host: a machine with a public IP and an open SSH port that you log into from the outside, and from which you hop inward. The thing I built plays the same role - the single controlled doorway into a private network - but runs it in reverse.
The bastion is a small VM (10.0.1.11) sitting on the same subnet as everything else. It runs exactly one thing: cloudflared, the Cloudflare Tunnel daemon, as a systemd service. On startup cloudflared makes an outbound connection to Cloudflare's edge and holds it open. Over that connection it advertises a route: "I can reach 10.0.1.0/24." That is the entire job. It accepts nothing inbound.
| Classic bastion | This reverse bastion | |
|---|---|---|
| Connection direction | accepts inbound SSH on a public IP | dials outbound to Cloudflare; nothing inbound |
| Public exposure | port 22 open to the internet | no listening port; port-scan it and you get silence |
| How you connect | SSH into it, then hop onward | nothing - WARP routes you straight to 10.0.1.x |
| What it is | a login waypoint you pass through | a router that splices Cloudflare's network onto the subnet |
That last row is the important mental model. You do not log into this bastion and jump. It is not a waypoint; it is a connector. WARP makes your laptop a virtual peer on 10.0.1.0/24, and the bastion is simply the device on the far end that forwards your packets onto the wire. Traffic transits it. Nobody opens a shell on it as a routine step. Because it only ever dials out, there is no attack surface to harden beyond keeping the box and cloudflared patched.
How a packet actually flows
Concretely, here is what happens when a developer (WARP running, signed in) types ssh [email protected].
Step by step:
- The laptop wants
10.0.1.5. The WARP client sees that this destination is inside a route it manages (10.0.0.0/16), grabs the packet, encrypts it, and ships it to the nearest Cloudflare edge. This only happens if the device is enrolled and the user has passed Google SSO. No enrollment means no route, which means the packet has nowhere to go. - The Cloudflare edge knows that
10.0.0.0/16is advertised by the bastion's tunnel, so it sends the packet down that tunnel. cloudflaredon the bastion (10.0.1.11) receives it and emits it onto its local subnet, where it walks to10.0.1.5:22.- The NSG sees the packet arriving with a source inside the VNet (the bastion's
10.0.1.11), which the default intra-VNet allow rule permits, so it reachessshd. - Return traffic retraces the path. You get a shell.
SSH is not special here - it is just one port. The exact same path carries https://10.0.1.5 to the app, the RabbitMQ console on 10.0.1.4:15672, Postgres on 5432, anything. The bastion advertises the whole subnet, so every private service becomes reachable to an enrolled device without a single one of them exposing a public port.
Identity is the gate, not the IP
This is the part that makes the whole thing worth it. Reaching 10.0.1.x requires an enrolled WARP device, and enrollment requires Google SSO against our Workspace domain (call it company.example). The firewall has no idea what IP you are on, and does not need to. The gate is your identity.
Onboarding a developer is: they install WARP, sign in with their company.example Google account, and they can ssh [email protected]. Offboarding is one step - remove them from the identity provider (or the WARP enrollment policy) and their route evaporates. No NSG edit, no rotating a shared key, no chasing authorized_keys across boxes. Their laptop simply stops being a peer on the network.
A trap worth flagging: two different "SSO" settings
While setting this up I tripped over something that is worth its own warning, because the two settings look identical and reuse the same Google login.
Cloudflare has two separate places Google can act as a sign-in method:
- The Zero Trust identity provider - this is the one you want. It authenticates end users for WARP enrollment and Access applications. This is what gates the cluster.
- An account-level dashboard SSO connector - a newer, self-serve feature that enforces SSO for logging into the Cloudflare dashboard itself, for every member of your email domain.
I had accidentally enabled the second one. The symptom: logging into dash.cloudflare.com with my company.example email started bouncing me through <team>.cloudflareaccess.com ("Log in to SSO App") before landing in the dashboard. It looked like the WARP identity provider had taken over dashboard login. It had not - it was a completely separate connector that just happened to point at the same Google.
The fix is in Members -> Settings, where you disable the SSO connector (type the domain to confirm). One caveat before you do: make sure your Cloudflare user has a password set first (SSO-only accounts often do not), or use "Forgot password" to set one, so you do not lock yourself out of the dashboard. Disabling the dashboard connector does not touch the Zero Trust identity provider - they are different objects - so cluster access keeps working throughout.
The lesson: "I set up Google SSO in Cloudflare" is ambiguous. There are two planes. Know which one you are editing.
The split-tunnel trap: RFC 1918 and longest-prefix-match
Here is the bug that cost me an afternoon, and it is a good one because it fails completely silently.
WARP is a split-tunnel VPN. On the laptop it installs a virtual network interface and edits the routing table to decide, per destination prefix, which traffic goes through the tunnel and which goes out your normal network card. By default it runs in Exclude mode: "send everything through WARP, except the prefixes on this list."
That default exclude list ships with the RFC 1918 private ranges already in it - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. The reasoning is sound: those are local networks. When your laptop talks to your home router at 192.168.1.1 or a printer on the office LAN, you want that to stay on the physical wire, not get hauled out to Cloudflare and back. So WARP says: private ranges are local, leave them on the normal interface.
But my Azure subnet is 10.0.1.0/24, which lives inside the excluded 10.0.0.0/8. So with the stock config, ssh 10.0.1.5 did this:
- The OS consults the routing table. WARP's exclude for
10.0.0.0/8means there is no route for10.0.1.5pointing at the tunnel - it is treated as on-link/local. - The laptop tries to reach
10.0.1.5on its own physical LAN, where nothing by that address exists. - The packet dies. It times out. The tunnel is never even consulted.
No error pointing at WARP. The connection just hangs. That is what makes it nasty.
Exclude vs Include - who owns the default route
The clean way to understand the fix is to look at which side owns the default route, 0.0.0.0/0.
Before (Exclude mode) - WARP grabs the default route and points it at its own interface. Everything tunnels by default; the exclude list installs more-specific routes that send certain prefixes back to the physical card:
| Destination | Route it matches | Path |
|---|---|---|
0.0.0.0/0 (the default) | WARP interface | tunnel |
10.0.0.0/8 (excluded) | physical card | local - this is what swallowed 10.0.1.5 |
After (Include mode) - WARP stops touching the default route. The physical card keeps 0.0.0.0/0. WARP only installs routes for the prefixes you explicitly list:
| Destination | Route it matches | Path |
|---|---|---|
10.0.0.0/16 (included) | WARP interface | tunnel |
0.0.0.0/0 (the default) | physical card | normal internet |
I switched the profile to Include mode and added a single entry: 10.0.0.0/16, the whole VNet. Now the routing decision per packet is exactly what you would draw:
The engine doing the work is plain longest-prefix-match. A packet to 10.0.1.5 matches both 10.0.0.0/16 and the catch-all 0.0.0.0/0, but /16 is more specific than /0, so it wins and goes to the tunnel. A packet to anything else matches only the default /0, which now lives on the physical card, so it goes out normally. The proof is satisfying: with WARP on after the change, curl ifconfig.me returns my real ISP address, while ssh 10.0.1.5 still connects. Only cluster traffic rides Cloudflare; my browsing does not.
There is a subtlety worth a sentence. The original instinct in Exclude mode is to just delete 10.0.0.0/8 from the exclude list. That works (the blunt fix), but it means all of 10.x now tunnels, including any genuinely-local 10.x network you might be on. The surgical alternative is to keep most of 10.x local and carve out only 10.0.1.0/24 for the tunnel - more list entries, no collateral. I sidestepped all of it by moving to Include mode, where you list only what you want tunneled and nothing else is affected. For a private-network-access tool, Include mode is simply the right default - it turns WARP from a full VPN into "reach the cluster, leave everything else alone."
Closing the cluster: the firewall flip
With the bastion path proven, I deleted the old access plane. Every single-IP *-admin allow rule came out. What remains on the subnet NSG is minimal:
| Priority | Rule | Ports | Source | Purpose |
|---|---|---|---|---|
| 390 | bastion-ssh | 22 | 10.0.1.11 (bastion) | the only port-22 rule - SSH from the bastion only |
| 65000 | AllowVnetInBound (Azure default) | all | VirtualNetwork | service-to-service + WARP-via-bastion to any port |
| 65001 | AllowAzureLoadBalancerInBound (default) | all | AzureLoadBalancer | health probes |
| 65500 | DenyAllInBound (Azure default) | all | any | closes every internet inbound |
The whole security posture now rests on Azure's default DenyAllInBound at priority 65500. Nothing public punches through it, because I removed all the rules that used to. Intra-VNet traffic is allowed by the default AllowVnetInBound, which is exactly how the bastion (a VNet member) reaches everything once a packet has come down the tunnel.
The test I cared about most: turn WARP off and confirm the cluster is dark. From my laptop with WARP disconnected, probing both the private and public addresses:
egress IP: <my real ISP IP> (not a Cloudflare address - WARP is off)
PRIVATE IPs (10.0.1.x): all blocked (no route exists without WARP)
PUBLIC IPs (the VMs): all blocked (NSG DenyAllInBound; nothing listening publicly)
Both halves matter. The private IPs are unreachable because, without WARP, there is no route to 10.0.1.0/24 at all - it is non-routable space on the public internet. The public IPs are unreachable because the firewall denies all inbound and there is no public listener anyway (cloudflared dials out). With WARP off, every admin door is shut. With WARP on and a valid identity, the private doors open. That is the entire model in one test.
One honest note on scope. Everything above is the access plane - how people and admins reach the cluster. There is a second, separate plane for end users that I am building next. The mobile app needs a few backend APIs over the public internet, so a dedicated ingress VM running Caddy terminates one real TLS certificate for a single public hostname (think app.example.com) and exposes exactly port 443 (plus 80, only so the certificate authority can complete its HTTP challenge), routing to a short allow-list of app services. That is a single, deliberate, audited door - and it changes nothing here: SSH, the datastores, the admin consoles, and every internal service stay WARP-only and dark. The two planes are independent. "Closed by default, open exactly one door on purpose, behind a real cert" is the design, not an exception to it.
Operating it
Onboarding a developer. Install the WARP client, sign in with the company.example Google account, done. They reach any box by private IP: ssh [email protected], https://10.0.1.5 for the app, http://10.0.1.4:15672 for the RabbitMQ console. No per-person firewall rule, ever.
Adding a new VM. Drop it on the subnet and it is reachable through the bastion immediately - the bastion advertises the whole /24, and the default AllowVnetInBound covers intra-VNet traffic. There is no new SSH rule to write. New provisioning scripts deliberately open nothing inbound; they rely on this model.
Break-glass. If cloudflared or WARP is ever down and I need in, the escape hatch is the Azure Serial Console, which reaches the VM out-of-band without any network path. Worst case I can also temporarily add a single scoped NSG rule from a known IP, then delete it.
Cost. Cloudflare Zero Trust is free up to 50 users, which a small team is nowhere near. The only running cost is the bastion VM, a roughly 4-to-8-dollar-a-month box that does nothing but hold a tunnel open. The tunnel itself has no throughput cap.
The limit that actually bites. On the free tier, audit logs (who connected when) are retained for 24 hours. For a dev cluster that is fine; if I needed a longer trail I would export logs to our own storage rather than pay up.
What I would do differently
A few honest caveats.
The bastion is a single point of failure for the SSH/admin plane. If it dies, nobody can reach the cluster over WARP until it comes back (app traffic to public ingress is a separate plane and unaffected). For a dev environment that is an acceptable trade - it is one tiny, patchable box - but for production I would run two connectors so the tunnel survives one going down. cloudflared is stateless, so that is straightforward.
On the tunnel protocol, WARP now defaults to MASQUE (over HTTP/3) rather than WireGuard. I left it on MASQUE - it traverses restrictive networks better because it looks like ordinary HTTPS, and for SSH the latency difference is irrelevant. WireGuard is the fallback to try only if some network blocks QUIC.
Finally, the VMs still carry public IPs purely for outbound (pulling images, OS updates). Nothing listens on them, but I could remove them entirely behind a NAT gateway to shrink the footprint to literally zero public surface. It was not worth the effort for a dev cluster, but it is the logical next step.
Conclusion
The shift that made all of this click was giving up on the IP address as a unit of trust. An IP tells you a network location the person does not own and that changes on its own. Identity tells you who they are and lets you revoke them cleanly. Once you accept that, the architecture falls out: a connector that dials outward so there is no public door, a client that makes each laptop a gated peer on the private subnet, an identity provider deciding who gets to be a peer, and a firewall whose default-deny does the rest. The bastion is the doorway, WARP is the key, SSO decides who holds a key, and DenyAllInBound keeps everything else locked.
Stop authenticating IP addresses. A reverse bastion that dials out (no public port), WARP that makes each laptop a gated peer on the private subnet, and SSO that decides who gets to be a peer - together they give a whole team SSH to a fully private cluster with zero internet-facing inbound on the access plane (a public app ingress, when you need one, is a separate deliberate door). And before you fight a hung connection, check your split-tunnel: if your cluster lives inside an RFC 1918 range that the VPN excludes by default, your packets never reach the tunnel at all.
References
- Cloudflare Tunnel (connect networks) - what
cloudflaredis and how a tunnel dials out. - Connect to a private network with WARP - advertising a CIDR over the tunnel so WARP clients can route to it.
- WARP split tunnels (Include vs Exclude) - the exact setting behind the RFC 1918 trap.
- Cloudflare Zero Trust plans and pricing - the free tier and the 50-user line.
- Set up dashboard SSO - the account-level connector I confused with the Zero Trust identity provider.
- RFC 1918 - private address allocation - why
10.0.0.0/8is "local" by default. - RFC 5737 - address blocks reserved for documentation - the placeholder public IPs used in this post.
- Azure NSG default security rules -
AllowVnetInBoundandDenyAllInBound, the rules the whole posture rests on.