Skip to content
Gary Wu
Go back

Bridging Local Compute and Cloud APIs

Edit page

Cloudflare Workers run at the edge and cannot reach private IPs. Your GPU workstation, FFmpeg renderer, or local TTS server sits behind CGNAT — invisible to your cloud pipeline. Tailscale Funnel is the bridge: it exposes any local port as a public HTTPS endpoint that Workers can fetch from, without opening firewall holes or managing certificates. This article walks through the full setup, the WSL2 edge cases, when to use Tailscale versus Cloudflare Tunnel, and how to wire both into a capability-based agent pipeline.


The Constraint

Cloudflare Workers execute in V8 isolates on CF’s global edge network. They make outbound fetch() calls like any JavaScript runtime — but with one hard limit: they cannot reach RFC1918 private addresses (192.168.x.x, 10.x.x.x) or RFC6598 CGNAT ranges (100.64.0.0/10).

Tailscale assigns addresses in the 100.64.0.0/10 CGNAT block. Your GPU workstation, home NAS, or local development machine appears in your tailnet as something like 100.101.102.103. From a Cloudflare Worker, that address is unreachable — the request simply fails.

This is not a Cloudflare bug. The edge network does not have a route to your private mesh and never will. The question is how to expose local compute to the cloud pipeline without compromising the security properties of a private mesh.

The wrong answer: open a firewall port and assign a static IP. Static IPs are fragile, raw TCP exposure is a security liability, and you lose the private mesh benefits entirely.

The right answer: Tailscale Funnel.


The Bridge: Tailscale Funnel

Tailscale Funnel is a feature that exposes a local port through the Tailscale relay infrastructure to the public internet. The result is a stable https://[machine].[tailnet].ts.net URL that any HTTP client — including Cloudflare Workers — can reach.

The routing path looks like this:

Worker → fetch("https://gpu-worker-01.ts.net:3001/exec")
       → Tailscale relay (DERP)
       → GPU workstation (capability HTTP server on :3001)

The machine never exposes a raw port to the internet. Traffic goes through Tailscale’s relay, TLS is terminated at the funnel endpoint, and the local service sees plain HTTP on localhost. The machine still participates in the private tailnet — other tailnet nodes reach it via WireGuard P2P as before. Funnel is additive; it does not change the private mesh.

The capability server running on the workstation is a lightweight HTTP service. It accepts /exec POST requests, validates an auth token, shells out to FFmpeg (or whatever the capability is), writes output to an R2 bucket, and returns the R2 key. The Cloudflare Worker that invoked it never knows or cares that it is talking to a home workstation rather than a cloud VM.


Setup: Exposing a Capability Server

Step 1: Join the tailnet

# On the GPU workstation — Linux
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --auth-key=tskey-auth-... --hostname=gpu-worker-01

On first run, tailscale up completes device authentication. The --hostname flag sets the MagicDNS name. After this command the machine is reachable from other tailnet nodes at gpu-worker-01.[tailnet].ts.net.

Step 2: Enable Funnel

# Expose local port 3001 to the public internet via Funnel
sudo tailscale funnel 3001

Tailscale creates a public HTTPS endpoint at https://gpu-worker-01.[tailnet].ts.net. Requests to port 443 on that URL are relayed to localhost:3001 on the workstation. The certificate is managed automatically — Tailscale provisions and rotates it.

Confirm it is working:

curl https://gpu-worker-01.[tailnet].ts.net/health
# {"status":"ok","capabilities":["ffmpeg-render","ffprobe"]}

Step 3: Start the capability server

# Minimal capability server — Node.js example
node capability-server.js --port 3001 --token $CAPABILITY_TOKEN

The server should handle at minimum:

GET  /health          → 200 {"status":"ok","capabilities":[...]}
POST /exec            → runs the capability, returns result

Every /exec request must validate the token in the Authorization header before doing anything. Funnel endpoints are public — anyone who discovers the URL can send requests.

Step 4: Register with API Mom

curl -X POST https://api.mom/capabilities/register \
  -H "Authorization: Bearer $API_MOM_TOKEN" \
  -d '{"capability":"ffmpeg-render","provider":"gpu-worker-01.[tailnet].ts.net","port":3001}'

API Mom’s CapabilityRegistryDO stores the Funnel hostname. When a pipeline worker needs ffmpeg-render, the DO resolves it to the current provider and returns the Funnel URL. No IP addresses anywhere in the registry.


The WSL2 Problem

Tailscale Funnel does not work correctly when Tailscale runs inside WSL2. The issue is architectural: WSL2 runs in a Hyper-V VM with a virtual network interface. Tailscale inside WSL2 has a different IP from the Windows host. Funnel, which is designed to route to localhost, ends up routing to the WSL2 VM’s localhost — which is not the same as the Windows host’s localhost, and is not accessible from outside the VM boundary.

The correct setup for Windows machines where the actual work runs in WSL2:

Internet
  → Tailscale relay
  → Windows host (Tailscale runs here, Funnel configured here)
  → Windows reverse proxy (Caddy or nginx) listening on localhost:3001
  → WSL2 (capability server running here, reachable at WSL2 IP)

Windows-side proxy with Caddy

Install Caddy on Windows, then create a Caddyfile:

:3001 {
    reverse_proxy {wsl_ip}:3001
}

Where {wsl_ip} is the WSL2 VM’s IP. The WSL2 IP changes on every WSL restart, so automate its retrieval:

# In a startup script on the Windows host
$wslIp = (wsl hostname -I).Trim().Split(" ")[0]
(Get-Content C:\Caddy\Caddyfile) -replace '{wsl_ip}', $wslIp | Set-Content C:\Caddy\Caddyfile
caddy reload --config C:\Caddy\Caddyfile

Then configure Funnel on the Windows host (not inside WSL2):

# In PowerShell on Windows host
tailscale funnel 3001

Now the path is: Funnel → Windows Caddy on :3001 → WSL2 capability server on :3001. The capability server in WSL2 runs normally and does not need to know about Tailscale at all.

An alternative that avoids the proxy entirely: run the Tailscale daemon inside WSL2 but use tailscale serve (not funnel) combined with the Windows host’s Tailscale doing the actual Funnel. This is more complex and not worth the indirection unless you specifically need WSL2-based Tailscale for other reasons.


MagicDNS for Service Discovery

Every machine that joins a tailnet gets a stable DNS name via MagicDNS:

gpu-worker-01.[tailnet].ts.net   → GPU workstation (ffmpeg-render, ffprobe)
tts-server.[tailnet].ts.net      → TTS machine (voice synthesis)
nas-01.[tailnet].ts.net          → NAS (media storage, not exposed via Funnel)

The names persist across IP changes, restarts, and network changes. A machine that roams from home ethernet to a coffee shop WiFi keeps the same MagicDNS name — Tailscale updates the routing automatically.

For the capability registry, this means API Mom stores Funnel hostnames rather than IP addresses:

// CapabilityRegistryDO — capability lookup
async resolve(capability: string): Promise<CapabilityProvider> {
  const row = await this.db.get(
    'SELECT funnel_host, port FROM capabilities WHERE name = ? AND active = 1',
    [capability]
  );
  // Returns: { funnel_host: "gpu-worker-01.ts.net", port: 3001 }
}

The Worker that needs the capability fetches from https://${row.funnel_host}:${row.port}/exec. No hardcoded IPs. No DNS records to maintain. If the workstation rejoins with a new tailnet or a different machine takes over the capability, you update one row in the registry.


Pre-Auth Keys for Bootstrap

A significant friction point in distributed capability systems is bootstrapping: how does a new machine join the network and register itself without manual steps?

Tailscale pre-auth keys solve the join step. One-time or reusable keys can be generated from the admin console or via the Tailscale API. An install script can use the key to join the tailnet non-interactively:

curl -fsSL https://api.mom/install/ffmpeg-render | TAILSCALE_AUTH_KEY=tskey-auth-... bash

The install script receives the auth key as an environment variable and handles the full setup sequence:

#!/usr/bin/env bash
set -euo pipefail

# 1. Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh

# 2. Join the tailnet
sudo tailscale up \
  --auth-key="${TAILSCALE_AUTH_KEY}" \
  --hostname="gpu-worker-$(hostname | md5sum | head -c 6)"

# 3. Enable Funnel on port 3001
sudo tailscale funnel 3001

# 4. Install and start the capability server
curl -fsSL https://api.mom/dist/capability-server-linux-amd64 -o /usr/local/bin/cap-server
chmod +x /usr/local/bin/cap-server

# 5. Register with API Mom
FUNNEL_HOST=$(tailscale status --json | jq -r '.Self.DNSName' | sed 's/\.$//')
curl -X POST https://api.mom/capabilities/register \
  -H "Authorization: Bearer ${API_MOM_REGISTER_TOKEN}" \
  -d "{\"capability\":\"ffmpeg-render\",\"provider\":\"${FUNNEL_HOST}\",\"port\":3001}"

# 6. Start the server
cap-server --port 3001 --token "${CAPABILITY_TOKEN}" &

After this script completes, the machine is in the tailnet, the Funnel endpoint is live, and API Mom knows about it. The Cloudflare pipeline can route work to it within seconds. No SSH, no manual configuration, no firewall rules.

Pre-auth keys can be scoped to specific ACL tags, marked as ephemeral (auto-expire when the device goes offline), or reusable for a fleet of identical machines. Use ephemeral keys for auto-scaled worker pools; use reusable keys for stable personal machines.


Tailscale Funnel vs. Cloudflare Tunnel

Both tools expose local services to the internet. They solve the same surface problem from different directions.

AspectTailscale FunnelCloudflare Tunnel
Custom domainNo — .ts.net onlyYes — any domain you control
DDoS protectionNoYes — full CF network protection
LatencyLower when Tailscale relay is nearbyHigher — always routes through CF edge
Certificate managementAutomatic (.ts.net)Automatic (your domain)
Private mesh accessYes — same nodes get WireGuard P2PNo
Auth integrationTailscale ACLsCloudflare Access (ZTNA)
Setup complexityLowerHigher (cloudflared daemon + config)
CostIncluded in Tailscale planFree tier available; paid for scale
Protocol supportHTTPS, TCPHTTPS, SSH, RDP, arbitrary TCP

The practical choice is not either/or. The two tools serve different parts of the same system.

Use Tailscale Funnel when:

Use Cloudflare Tunnel when:

Use both on the same machine when:

Running both simultaneously is supported and common. They operate on different ports and different network paths.


Hybrid Architecture: Both on the Same Fleet

A complete GPU capability fleet uses both tools without conflict:

┌─────────────────────────────────────────────────────────┐
│  Cloudflare Pipeline                                     │
│  Scram-Jet Worker → API Mom CapabilityRegistryDO        │
│                          │                              │
│                    resolves to:                         │
│           gpu-worker-01.ts.net:3001 (Funnel)           │
└─────────────────────────────────────────────────────────┘

                    Tailscale relay

┌─────────────────────────────────────────────────────────┐
│  GPU Workstation (gpu-worker-01)                        │
│  ├── Tailscale daemon (WireGuard mesh + Funnel)        │
│  ├── Capability server on :3001                        │
│  └── cloudflared (optional — for public endpoints)     │
│                                                         │
│  Private mesh: gpu-worker-02, nas-01, dev-laptop       │
│  All connected via WireGuard P2P within tailnet        │
└─────────────────────────────────────────────────────────┘

The private mesh remains private. gpu-worker-02 reaches gpu-worker-01 via WireGuard — no relays, no internet exposure. Funnel is a selectively opened window, not a door left ajar. You control which ports are exposed and can revoke access by running tailscale funnel --off.


Security Considerations

Funnel endpoints are publicly reachable HTTPS URLs. The Tailscale relay handles TLS, but it does not handle application-level auth. Anyone who discovers the URL can send requests.

Three layers of defense at the capability level:

1. Token validation on every request

// capability-server.ts
app.post('/exec', async (req, res) => {
  const token = req.headers['authorization']?.replace('Bearer ', '');
  if (!token || token !== process.env.CAPABILITY_TOKEN) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  // ... proceed with capability execution
});

The token is a secret shared between API Mom and the capability server. API Mom includes it in every /exec request. The capability server validates it before doing any work.

2. Rate limiting

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 60 * 1000,   // 1 minute
  max: 30,               // 30 requests per minute per IP
  message: { error: 'Rate limit exceeded' }
});

app.use('/exec', limiter);

Even with a valid token, a buggy pipeline should not be able to saturate the GPU.

3. No sensitive data in URLs

Always use POST with a JSON body for capability invocations. URLs appear in access logs, Tailscale relay logs, and any intermediate proxy. The R2 keys, media paths, and render parameters should be in the body, not query strings.

// Wrong
GET /exec?input=s3://bucket/source.mp4&output=render&token=secret

// Right
POST /exec
Authorization: Bearer <token>
Content-Type: application/json
{"input_key":"uploads/source.mp4","output_key":"renders/out.mp4","preset":"1080p"}

Tailscale ACLs provide a fourth layer for private-to-private traffic. You can restrict which tailnet nodes are allowed to send traffic to which ports. For Funnel-exposed ports, ACLs apply to tailnet-internal traffic but not to the public Funnel endpoint — the internet-facing path bypasses ACL enforcement by design.


The Complete Flow

Putting everything together, the end-to-end path for a pipeline rendering job looks like this:

Scram-Jet pipeline triggers "ffmpeg-render" step


Scram-Jet Worker calls API Mom
  POST https://api.mom/capabilities/resolve
  {"capability": "ffmpeg-render"}


CapabilityRegistryDO queries capability registry
  → returns {provider: "gpu-worker-01.ts.net", port: 3001}


Scram-Jet Worker sends execution request
  POST https://gpu-worker-01.ts.net:3001/exec
  Authorization: Bearer <capability-token>
  {"input_key": "uploads/raw.mp4", "output_key": "renders/final.mp4", "preset": "4k-h265"}


Tailscale relay routes request to GPU workstation


Capability server on gpu-worker-01
  → validates token
  → shells out: ffmpeg -i /tmp/raw.mp4 -preset slow -crf 18 /tmp/final.mp4
  → writes output to R2: aws s3 cp /tmp/final.mp4 s3://media-store/renders/final.mp4
  → returns: {"r2_key": "renders/final.mp4", "duration_ms": 14200}


Scram-Jet pipeline continues with r2_key
  → media is in Media Store, accessible to downstream steps

The Cloudflare pipeline never touches a private IP. The GPU workstation never opens a raw firewall port. The Funnel URL is stable across IP changes and network roaming. API Mom’s registry stores the hostname, not an IP, so the system degrades gracefully when machines rejoin under a new IP.


When This Architecture Applies

The Tailscale Funnel bridge is the right tool when:

It is not the right tool when:

For capability-based agent pipelines, the Funnel bridge fills a gap that has no other clean solution: local hardware, cloud orchestration, no infrastructure overhead, no static IPs. The GPU workstation becomes a first-class node in the pipeline, interchangeable with a cloud VM from the Worker’s perspective, at near-zero operational cost.


References


Edit page
Share this post on:

Previous Post
The RFC Process for Multi-Repo Ecosystems
Next Post
Streaming Data Pipelines with Async Generators