The problem
We are migrating customer databases into Omni DB. Source databases live in on-prem data centers behind firewalls we do not control. The migration pipeline needs to read from on-prem databases and write to Omni DB on demand, triggered from a web app our customers use.
The architecture looks like this on paper.

The bottleneck was always going to be the same question. How does the Cloud Function, which sits on the public internet, talk to a database that lives inside an on-prem network that will not expose port 1433 to the world.
I tried four ways to bridge that gap. Three failed for protocol reasons. One worked because I stopped trying to bridge from outside and instead joined the inside.
What I was trying to solve
Concretely:
- A web app that lets a user say migrate this database
- An API Gateway and Cloud Function that receive the request and look up the VM endpoint
- A migration VM that needs to talk to on-prem databases
- Source databases (SQL Server, MySQL, Postgres) on the on-prem network, not reachable from the internet
- Omni DB as the destination, sitting somewhere accessible
The Cloud Function has a public IP. The on-prem network has nothing with a public IP. The two need to talk. That is the whole problem.
What I tried (none of it worked)

Each attempt failed or got ruled out. Quick summary first, then the details.
Attempt 1: Cloudflare Tunnel (quick tunnel)
Started with cloudflared tunnel --url, hoping for a free public hostname that forwards to localhost:1433.
Failed immediately. Cloudflare Tunnel is an HTTP/HTTPS reverse proxy. SQL Server speaks TDS, the Tabular Data Stream protocol, which is raw binary bytes with no HTTP framing. Every connection attempt came back as EOF because the tunnel had no valid HTTP request to forward. It received TDS prelogin bytes and had nothing to do with them.
Cloudflare Tunnel only speaks HTTP. It is the wrong tool for any raw TCP protocol.
Attempt 2: Cloudflare named tunnel with TCP ingress
Named tunnels do support TCP. That is the service: tcp://localhost:1433 config line. I spun one up.
But here is the catch. TCP ingress on Cloudflare only works if the connecting client is also running cloudflared with access tcp configured locally. The connection is brokered through Cloudflare’s edge using a Wireguard-style hop. You do not get a host:port you can plug into a normal client library. Your code has to be aware of Cloudflare’s access model.
For our consumer, which is pymssql or the .NET SqlClient, I need to plug a database driver in, not a tunnel client. Non-starter.
Attempt 3: Cloudflare Spectrum
This one would have actually worked. Spectrum gives you a real public TCP endpoint. Your service connects to something.cloudflare.com:1433, Cloudflare forwards raw TCP to your origin. No client-side software needed.
Locked behind the Pro plan. Paid. The infra team approved it as a backup option but wanted a free alternative first. Ruled out for the primary path.
Attempt 4: Tailscale Funnel
This one hurt because Tailscale is already approved and running for our team. Funnel exposes a service on your tailnet to the public internet through Tailscale’s edge nodes.
But Funnel has a strict rule. Every incoming connection must begin with a real TLS handshake at the edge. It is built for HTTPS. The moment a non-TLS byte shows up, Funnel drops the connection with Unexpected EOF (error 20017).
SQL Server’s TDS protocol sends raw prelogin bytes the moment a client connects. There is no TLS handshake in the protocol. To make Funnel work I would need:
- SQL Server configured for TDS 8.0 Strict encryption mode, which prepends a real TLS handshake before the TDS stream
- A client library that supports TDS 8.0 strict
Our stack is pymssql on Linux, which wraps FreeTDS. Neither supports TDS 8.0 strict today. Dead end for this stack.
The pattern in all four failures
Every tunnel solution I tried assumes one thing. The caller is on the public internet, the service is on a private network, and the tunnel bridges the two.
That is fine when the caller really is on the public internet. A mobile app, a browser, a third party service you do not control. For those, you need some form of public endpoint.
But if you control the caller, like another VM, another server, another piece of infrastructure you own, that assumption is wrong. You can install software on the caller. You can put it on the same private network as the database.
That was the unlock. I stopped trying to expose the database. I started joining machines to a private mesh.
What worked: Tailscale mesh, no tunnel
Three steps. Total time about half a day including approvals.
- Install Tailscale on the migration VM in the cloud. It gets a stable
100.x.x.xprivate IP from Tailscale’s CGNAT range. - Install Tailscale on a small Linux box inside the on-prem network that has line of sight to the source databases. It gets its own
100.x.x.xIP. - Approve both devices on the same tailnet. The on-prem box’s Tailscale IP is now reachable from the migration VM in the cloud.
The on-prem box is just a jump host. It does not need to do anything fancy. It just needs to be able to reach the databases on their native ports. The migration VM talks to the on-prem jump host over the mesh, the jump host proxies the connection to the local database.
Then from the migration VM:
import pymssql
conn = pymssql.connect(
server="100.x.x.x", # the on-prem jump host's Tailscale IP
port=1433,
user="migrator",
password="...",
database="source_db",
)
cursor = conn.cursor()
cursor.execute("SELECT * FROM dbo.customers")
for row in cursor.fetchall():
print(row)
End to end. Working. No tunnel, no TLS termination layer, no public exposure, no firewall changes.
Why this works at the network layer
Tailscale devices on the same tailnet talk to each other over a WireGuard-encrypted mesh. When both devices can reach each other directly over the internet, the connection is point to point with WireGuard encryption. When they cannot, Tailscale relays through its DERP servers using the same encryption.
The encryption happens at the network layer. There is no requirement for the application to do its own TLS handshake. The database sees a normal TCP connection from another tailnet device. No protocol mismatch, no handshake gymnastics.
That last bit matters. The reason Funnel failed is exactly because it required an application-level TLS handshake. The mesh does not, because encryption is handled by WireGuard below the transport layer.
SQL Server :1433
100.x.x.x] ---|WireGuard mesh| B[Migration VM
in cloud
100.x.x.x] style A fill:#fee2e2,stroke:#dc2626,color:#1f2937 style B fill:#dcfce7,stroke:#22c55e,color:#1f2937
The full pipeline, end to end
Here is what the migration actually looks like once the mesh is in place. Each step is a real call over a real protocol, with the network boundaries called out.

The numbered steps map directly to the code below. Step 1 is the user clicking a button. Step 10 is the response back to them.
# Cloud Function (steps 1, 2, 3, 9, 10)
def trigger_migration(request):
body = request.get_json()
db_name = body["db_name"] # e.g. "mysql" or "sqlserver"
table = body["table"] # e.g. "users"
# Step 2 + 3: pull the VM endpoint from Secret Manager
vm_endpoint = secret_manager.get(f"vm-endpoint-{db_name}")
# Step 4: kick off the migration job over the mesh
response = requests.post(
f"{vm_endpoint}/jobs",
json={"table": table, "batch_size": 1000},
timeout=300,
)
return {"status": "ok", "rows": response.json()["rows"]}
# Migration VM (steps 4 through 8)
@app.post("/jobs")
def run_migration(job: Job):
db = connect_to_source(job.db) # over Tailscale, e.g. pymssql
omni = connect_to_omni()
rows = db.execute(f"SELECT * FROM {job.table}").fetchall()
omni.bulk_insert(job.table, rows, batch_size=job.batch_size)
return {"rows": len(rows), "status": "complete"}
The Cloud Function never touches a database. The migration VM never accepts a public connection. The on-prem jump host never accepts anything but tailnet traffic. Every piece is on the network it needs to be on, and no piece is on a network it should not be on.
The full architecture, one more time
For completeness, here is the diagram again with the actual protocols on each edge labeled.

Notice what is and is not on this diagram.
What is on it:
- The web app, API Gateway, and Cloud Function all sitting in the public cloud, talking to each other over HTTPS
- The migration VM inside the Tailscale mesh, talking to the Cloud Function over HTTPS and to the database over the native wire protocol
- The on-prem network, with the source databases and Omni DB reachable only via the mesh
What is not on it:
- Any direct public path to the on-prem network
- Any port forwarding rules on the corporate firewall
- Any SSH tunnels or jump-host chains the user has to remember
- Any custom client libraries that need to know about a tunnel
The mesh handles all of that invisibly.
Things I would do differently
Check the client library’s protocol support before committing to a stack. pymssql does not support TDS 8.0 strict. If I had checked that first, I would have known Funnel was a dead end for this stack before even trying it.
Test the actual protocol mismatch early. When I first hit EOF errors with Cloudflare Tunnel, I assumed it was a config issue. It took a while to confirm it was a fundamental protocol incompatibility. Knowing that TDS does not speak HTTP would have saved me an afternoon.
Skip the free tier rabbit holes for paid products. If Spectrum is the only thing that fits and it costs money, that is a conversation with the infra team, not something to engineer around. I burned time on Spectrum research before accepting that paid was not happening.
Get the on-prem team involved earlier. The reason this worked was a small Linux box on the on-prem network running Tailscale. That box had to be approved, imaged, and installed. Starting that conversation a week earlier would have shaved a day off the timeline.
Short version
If your problem is a cloud service that needs to talk to an on-prem database that will not be exposed publicly:
- Stop trying to expose the database. There is no clean way to do it without protocol tricks or paid plans.
- Stand up a tiny jump host on the on-prem network that can see the database.
- Join that jump host and your cloud VM to the same Tailscale tailnet.
- Talk to the database through the jump host over the mesh. Standard
pymssql, standardpsycopg2, standardmysql.connector. No custom client, no protocol gymnastics.
The lesson, plain and simple: when you cannot expose a database, do not expose it. Join the network it lives on.