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.
| Aspect | Tailscale Funnel | Cloudflare Tunnel |
|---|---|---|
| Custom domain | No — .ts.net only | Yes — any domain you control |
| DDoS protection | No | Yes — full CF network protection |
| Latency | Lower when Tailscale relay is nearby | Higher — always routes through CF edge |
| Certificate management | Automatic (.ts.net) | Automatic (your domain) |
| Private mesh access | Yes — same nodes get WireGuard P2P | No |
| Auth integration | Tailscale ACLs | Cloudflare Access (ZTNA) |
| Setup complexity | Lower | Higher (cloudflared daemon + config) |
| Cost | Included in Tailscale plan | Free tier available; paid for scale |
| Protocol support | HTTPS, TCP | HTTPS, 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:
- The client is a Cloudflare Worker or another cloud service that just needs an HTTPS endpoint
- You want zero-config certificate management
- The machine is a personal workstation or development node
- You also want the private mesh benefits for SSH, file access, and internal tooling
Use Cloudflare Tunnel when:
- The endpoint needs to be served from a custom domain (
render.yourcompany.com, notmachine.ts.net) - You need DDoS protection at the network layer
- The endpoint serves public users, not internal pipeline workers
- You need Cloudflare Access integration for zero-trust access control
Use both on the same machine when:
- Tailscale Funnel handles internal pipeline invocations (Worker → GPU workstation)
- Cloudflare Tunnel handles any public-facing interface that needs your custom domain
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:
- You have local compute (GPU, specialized hardware, high-memory machines) that cloud providers cannot replicate cheaply
- Your cloud pipeline is on Cloudflare Workers or another edge runtime that cannot reach private IPs
- You want to avoid the operational overhead of VPNs, static IPs, and firewall rules
- The capability is invoked occasionally enough that latency through the Tailscale relay is acceptable
It is not the right tool when:
- You need sub-100ms invocation latency at high volume — relay overhead adds 20-80ms depending on geography
- The capability serves public users who should not see a
.ts.netURL (use Cloudflare Tunnel instead) - The capability produces large output that should stream back to the caller — prefer writing to R2 and returning a key
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
- Self-Hosted to Serverless: Migrating a Real-Time WebSocket Relay to Cloudflare — Tailscale and Cloudflare as complementary layers; the composition pattern that motivates this bridge
- Tailscale Funnel documentation — official reference for Funnel setup, ACL requirements, and limitations
- Tailscale pre-auth keys — ephemeral keys, reusable keys, and scoping by ACL tag
- Cloudflare Tunnel vs. Tailscale Funnel — feature comparison from the Tailscale perspective