Repository files navigation

Captun (cap[nweb] tun[nel])

Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a Node process over Cap'n Web.

Quick start

Expose a local HTTP server with the hosted captun.sh tunnel service:

npx captun 3000

That prints a public URL like https://abc123.captun.sh and forwards requests to localhost:3000.

If you want your own tunnel server, deploy a captun Worker to your Cloudflare account. You can think of this like your own personal ngrok server, but faster:

deploy expects Cloudflare auth to already be available. Run npx wrangler login once, or set CLOUDFLARE_API_TOKEN for CI and other non-interactive shells.

npx captun deploy

The deploy command uses wrangler under the hood to deploy an opinionated captun Tunnel Gateway to your Cloudflare account, then stores its gateway URL and token in an XDG config file for later tunnel commands.

Programmatic usage

You can use the hosted service from code for receiving HTTP requests. First npm install captun to add it as a dependency. Then create it:

import{createCaptunTunnel}from"captun";consttunnel=awaitcreateCaptunTunnel({fetch: async(request)=>{consturl=newURL(request.url);if(url.pathname.endsWith("/webhook")){console.log("Received a webhook:",awaitrequest.json());returnResponse.json({ok: true});}returnnewResponse("not found",{status: 404});},});console.log(`Listening to webhooks on ${tunnel.url}/webhook`);awaitnewPromise(()=>{});// stay alive until killed

That's all you need! No local ports, just a fetch function.

WebSockets

Tunnels forward WebSockets too: npx captun 3000 exposes any local WebSocket server (socket.io, ws, Bun, Deno, ...) with handshake headers, subprotocols, binary messages, and close codes passing through. In code, a fetch handler accepts WebSockets Workers-style on any runtime:

import{createCaptunTunnel,createWebSocketResponse,isWebSocketUpgradeRequest,WebSocketPair,}from"captun";awaitcreateCaptunTunnel({fetch(request){if(!isWebSocketUpgradeRequest(request))returnnewResponse("hello");constpair=newWebSocketPair();pair[1].accept();pair[1].addEventListener("message",(event)=>pair[1].send(`echo:${event.data}`));returncreateWebSocketResponse(pair[0]);},});

Connections are relayed message by message over the tunnel, so ping/pong and compression are per-hop, close codes outside 1000/3000–4999 degrade to a plain close, and messages are capped at 16MiB so one oversized frame can't take down the tunnel.

Vite plugin

captun/vite serves your Vite dev server (and vite preview) through a public tunnel URL — handy for receiving webhooks against local code, sharing work in progress, or pointing remote devices and agents at your dev server.

// vite.config.tsimport{defineConfig}from"vite";importcaptunfrom"captun/vite";exportdefaultdefineConfig({plugins: [captun()],});

vite dev then prints a public URL next to the local ones:

 ➜ Local: http://localhost:5173/
➜ Captun: https://abc123.captun.sh

The plugin is a thin wrapper around createCaptunTunnel: it waits for the server to start listening, opens a tunnel, and forwards every public request to the local server. All client tunnel options pass straight through, plus two plugin-level callbacks:

captun({// createCaptunTunnel optionsgateway: process.env.CAPTUN_GATEWAY,// Tunnel Gateway URL; defaults to the hosted captun.sh servicename: "my-app",// Tunnel Name used in the public URL; random when omittedtoken: process.env.CAPTUN_TOKEN,// Connect Token; random when omitted// plugin optionsonTunnel: ({ url, token })=>{// runs once the tunnel is connected, e.g. to register a webhook URL;// replaces the default "➜ Captun: <url>" log},onError: (error)=>{// runs when creating the tunnel fails; replaces the default error log// (which leaves the server running). Rethrow to make the failure fatal.},});

After npx captun deploy, point the plugin at your own self-hosted gateway by passing your deployment's gateway and token (for example via environment variables, as above). To only tunnel on demand, make the plugin conditional in your config:

plugins: [process.env.TUNNEL ? captun() : undefined],

Caveats: WebSockets are not forwarded, so Vite HMR only works on the local URL — the tunnel is for plain HTTP (webhooks, previews, e2e tests). For an https dev server, Node must trust the server's certificate (self-signed dev certificates will fail the local hop).

Advanced usage

The captun worker.ts implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in iterate/iterate). For example, here's a weather application which allows mocking its egress to the weather API:

import{DurableObject}from"cloudflare:workers";import{acceptFetcherCapability,typeFetcherStub}from"captun";typeWeatherReporterEnv=Env&{WEATHER_REPORTER_EGRESS: DurableObjectNamespace<WeatherReporterEgressTunnel>;};exportclassWeatherReporterEgressTunnelextendsDurableObject<WeatherReporterEnv>{privateegressFetcher: FetcherStub|undefined;asyncfetch(request: Request){consturl=newURL(request.url);if(url.pathname==="/weather"){// Here's the value our app provides: fetching and gorgeously formatting weather dataconstcity=url.searchParams.get("city");constresponse=awaitthis.egressFetch(`https://wttr.in/${city}?format=j1`);constweather=awaitresponse.json<{current_condition: [{temp_C: string}]}>();returnnewResponse(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`,);}if(url.pathname==="/__intercept-egress-traffic"){// Here we set up our worker to allow clients/tests to intercept egress trafficthis.egressFetcher?.[Symbol.dispose]();const{ response, fetcher }=acceptFetcherCapability({onDisconnect: ()=>{if(this.egressFetcher===fetcher)this.egressFetcher=undefined;},});this.egressFetcher=fetcher;queueMicrotask(()=>voidfetcher.ready({url: newURL(request.url).origin}));returnresponse;}returnnewResponse("Not found\n",{status: 404});}getegressFetch(): typeoffetch{if(this.egressFetcher){returnasync(input,init)=>this.egressFetcher!.fetch(newRequest(input,init));}returnfetch;}}exportdefault{fetch(request: Request,env: WeatherReporterEnv){returnenv.WEATHER_REPORTER_EGRESS.getByName("default").fetch(request);},}satisfiesExportedHandler<WeatherReporterEnv>;

The core client/server pieces (createCaptunTunnel, acceptFetcherCapability, acceptFetcherCapabilityFromSocket, Fetcher, and FetcherStub) live in src/index.ts — small TypeScript wrappers around Cap'n Web. For a self-hosted Cloudflare Tunnel Gateway, copy or adapt src/server/worker.ts and the Durable Object binding in wrangler.jsonc. The Iterate-operated hosted service is separate: its product surface lives under src/hosted, with wrangler.hosted.jsonc as its deployment config.

Runtime Adapters for accepting Fetcher Capabilities outside Cloudflare Workers are implemented under src/server and exported as captun/node, captun/bun, and captun/deno. See examples/node, examples/bun, and examples/deno for the same small weather egress test running in each runtime.

Advanced CLI Usage

The CLI is mostly focused on ngrok-style use-cases. Without local config it uses the hosted captun.sh service. Once you have run npx captun deploy, further commands will pick up your self-hosted gateway URL and token from your machine's captun config. You can also pass them explicitly (for example, to create a tunnel using a deployment created from someone else's machine):

npx captun 3000 --gateway 'https://captun.youraccount.workers.dev' --token abc123

By default, the npx captun 3000 command will generate a name for the tunnel it creates. You can customise this with --name:

npx captun 3000 --name my-very-serious-tunnel-name

By default the worker routes /my-tunnel/foo/bar to the capnweb session for "my-tunnel", and becomes a corresponding HTTP request with pathname /foo/bar when it reaches your client.

Custom domains

Running npx captun deploy interactively walks you through where the tunnel URLs should live. There are four options, and which one is best for you depends on the kind of apps you want to tunnel to and whether you already have a domain on Cloudflare.

Routing is controlled by a single Worker env var, CUSTOM_HOSTNAME. When unset (workers.dev deploys), tunnels use folder routing: the first path segment is the tunnel name. When set (custom-domain deploys), tunnels use subdomain routing — the last DNS label before CUSTOM_HOSTNAME is the tunnel name, and anything to the left of it is ignored. The deploy wizard sets CUSTOM_HOSTNAME for you; the parsing logic lives in getTunnelNameFromUrl in src/server/tunnel-addressing.ts.

1. <tunnel>.<account>.workers.dev/<tunnel-name> (default)

Free, instant, no DNS setup. The tunnel URLs look like https://captun.<account>.workers.dev/demo and your app runs under the /demo path prefix.

Pick this if: you want the fastest possible setup, and the apps you're tunneling to are happy under a path prefix.

Caveat: apps that assume they live at / will misbehave — absolute redirects to /login, OAuth callbacks hardcoded to a root URL, cookies scoped to Path=/, and similar. If you hit any of those, pick one of the options below.

2. <tunnel>.your-domain.com (free wildcard on an existing zone)

Free, instant. Tunnel URLs become https://demo.your-domain.com/ — apps see a naked hostname, so path-prefix issues from option 1 disappear. Universal SSL covers first-level subdomains so no cert work is needed.

npx captun deploy --route '*.your-domain.com/*' --zone your-domain.com

Pick this if: you have a Cloudflare-managed domain you can dedicate to tunnels.

Caveat: the worker route *.your-domain.com/* will catch every otherwise-unrouted subdomain on this zone, which means you should only use this on a domain you've actually set aside for tunnels. Don't point it at your main production domain.

3. <tunnel>.captun.your-domain.com (requires Advanced Certificate Manager)

Tunnels are namespaced under captun. on your existing domain (or whatever subdomain prefix you pick in the wizard), so the rest of the zone is unaffected.

npx captun deploy --route '*.captun.your-domain.com/*' --zone your-domain.com

Universal SSL only covers the apex and first-level subdomains, so *.captun.your-domain.com (a second-level wildcard) needs a separately-ordered certificate. The wizard handles this by ordering an Advanced Certificate Manager certificate pack for *.captun.your-domain.com + captun.your-domain.com and waiting for it to become active.

Pick this if: you want clean naming on an existing domain without the foot-gun of option 2.

Caveat: ACM is $10/month per zone. The wizard checks whether ACM is already enabled and bails with a link to the dashboard if it isn't — there's no way to subscribe to ACM via API.

4. Dedicated tunnel domain

If you don't have a suitable Cloudflare-managed domain, registering a throwaway one (e.g. my-tunnels.com) and using it with option 2 ends up cheaper than enabling ACM for option 3 (~$9/year versus $10/month).

  1. Register a domain via Cloudflare Registrar or any third-party registrar.
  2. Add the domain to your Cloudflare account and wait for the zone to become active.
  3. Re-run captun deploy and pick option 2 for the new zone.

Sharding

By default, all tunnel names live in one warm CaptunServerShard Durable Object. That minimizes cold-start latency. Use --shards only when you need more aggregate throughput for many concurrent large responses:

npx captun deploy --shards 256

All of captun's public API (both the client createCaptunTunnel and the server-side acceptFetcherCapability) is exported from the single captun entry point. acceptFetcherCapabilityFromSocket(socket) is also exported for Workers that have already performed the WebSocket upgrade themselves.

Performance

On May 18, 2026 from London, one warm-shard Captun tunnel reached first fetch in 188ms p50. Rechecking provider startup on the same day showed ngrok was much faster than the earlier sample: one ngrok ad-hoc tunnel reached 451ms, and 10 concurrent ngrok tunnels reached 658ms p50. Cloudflared quick tunnels still took about 8.5-9s when successful because the trycloudflare.com hostname was printed several seconds before DNS/public routing was ready.

Ad-hoc tunnelFirst fetch
captun188ms
ngrok451ms (+140%)
cloudflared quick tunnel8.51s (+4,427%)
10 concurrent ad-hoc tunnelsSuccessfulp50p90p99
captun10/10172ms186ms189ms
ngrok10/10658ms (+283%)695ms (+274%)985ms (+421%)
cloudflared quick tunnel2/108.89s (+5,069%)9.00s (+4,739%)9.00s (+4,662%)

One shard is the default because it spins up fastest. More shards trade extra cold starts for more total throughput: 100 concurrent 2MiB streams through one shard took 26.34s p50, while 150 concurrent 2MiB streams spread over 256 warmed shards took 9.76s p50.

Captun startup chart

The scripts used for these numbers are scripts/benchmark-startup.ts and scripts/benchmark-streams.ts; the compact recorded results are in docs/performance, with notes in docs/benchmarks.md.

For test and development traffic, this should usually cost effectively nothing on Cloudflare: the Workers Free plan includes daily Worker requests, and Durable Objects have their own included free usage. Check pricing before serious volume, because connected Durable Objects cannot hibernate while the WebSocket is open.

How Does It Work?

We just pass fetch() through fetch(). No, really.

With Cap'n Web, the Node client opens a WebSocket RPC session to the Worker and exposes its local fetcher as the session's main capability. The Worker's tunnel handle is a stub for that capability, whose only interesting method is fetch(request). From then on, the Worker can forward public HTTP requests to that function and return the resulting Response.

All you need is fetch(). Requests, responses, headers, bodies, streams, SSE, and uploads are already web standards; this is the web-standards way this should work.

sequenceDiagram
participant HTTP as HTTP client
participant Gateway as Tunnel Gateway / CaptunServerShard
participant Client as Node client
Client->>Gateway: WebSocket RPC connect to ?captun-connect=1&captun-name=demo with fetcher as main capability
Gateway-->>Client: ready({ url })
HTTP->>Gateway: GET /demo/report
Gateway->>Client: fetch(request)
Client-->>Gateway: Response
Gateway-->>HTTP: Response
Loading

See examples/weather-reporter for a small workspace package that imports captun and has its own e2e tests.

Development

The Worker needs the CaptunServerShard Durable Object binding and migration from wrangler.jsonc. For local development:

pnpm install
pnpm run build
pnpm run dev

Run tests with pnpm test. The root e2e suite uses Miniflare by default; set CAPTUN_GATEWAY, with optional CAPTUN_TOKEN, to run the same cases against a deployed Worker.

End-to-end smoke tests for build, dry-run deploy, local wrangler dev, tunnel, and curl live in scripts/smoke/ with documentation in docs/smoke-test.md:

pnpm smoke
./scripts/smoke-test.sh list
./scripts/smoke-test.sh step-5-tunnel-local

Caveats

Captun is intentionally small. It is a reference implementation you can copy into a Worker or Durable Object, not a managed tunnel product.

It is fast but less durable than Cloudflare Tunnel. There is no redundant connection in another data center, and a connected Durable Object can still be restarted, so an in-flight request can fail.

Large binary streams are slower than small requests because a Response body crosses the Cap'n Web WebSocket/RPC session rather than getting spliced as a native HTTP socket. For webhook callbacks, mocked internet egress, local previews, and e2e tests, that tradeoff is usually fine.

Connecting a second client with the same tunnel name replaces the previous connection. Malformed percent-encoding in a folder tunnel name is rejected as a missing tunnel name.

About

Minimal Cap'n Web tunnel for Cloudflare Workers and Node

Resources

Stars

23 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Captun (cap[nweb] tun[nel])

Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a Node process over Cap'n Web.

Quick start

Expose a local HTTP server with the hosted captun.sh tunnel service:

npx captun 3000

That prints a public URL like https://abc123.captun.sh and forwards requests to localhost:3000.

If you want your own tunnel server, deploy a captun Worker to your Cloudflare account. You can think of this like your own personal ngrok server, but faster:

deploy expects Cloudflare auth to already be available. Run npx wrangler login once, or set CLOUDFLARE_API_TOKEN for CI and other non-interactive shells.

npx captun deploy

The deploy command uses wrangler under the hood to deploy an opinionated captun Tunnel Gateway to your Cloudflare account, then stores its gateway URL and token in an XDG config file for later tunnel commands.

Programmatic usage

You can use the hosted service from code for receiving HTTP requests. First npm install captun to add it as a dependency. Then create it:

import{createCaptunTunnel}from"captun";consttunnel=awaitcreateCaptunTunnel({fetch: async(request)=>{consturl=newURL(request.url);if(url.pathname.endsWith("/webhook")){console.log("Received a webhook:",awaitrequest.json());returnResponse.json({ok: true});}returnnewResponse("not found",{status: 404});},});console.log(`Listening to webhooks on ${tunnel.url}/webhook`);awaitnewPromise(()=>{});// stay alive until killed

That's all you need! No local ports, just a fetch function.

WebSockets

Tunnels forward WebSockets too: npx captun 3000 exposes any local WebSocket server (socket.io, ws, Bun, Deno, ...) with handshake headers, subprotocols, binary messages, and close codes passing through. In code, a fetch handler accepts WebSockets Workers-style on any runtime:

import{createCaptunTunnel,createWebSocketResponse,isWebSocketUpgradeRequest,WebSocketPair,}from"captun";awaitcreateCaptunTunnel({fetch(request){if(!isWebSocketUpgradeRequest(request))returnnewResponse("hello");constpair=newWebSocketPair();pair[1].accept();pair[1].addEventListener("message",(event)=>pair[1].send(`echo:${event.data}`));returncreateWebSocketResponse(pair[0]);},});

Connections are relayed message by message over the tunnel, so ping/pong and compression are per-hop, close codes outside 1000/3000–4999 degrade to a plain close, and messages are capped at 16MiB so one oversized frame can't take down the tunnel.

Vite plugin

captun/vite serves your Vite dev server (and vite preview) through a public tunnel URL — handy for receiving webhooks against local code, sharing work in progress, or pointing remote devices and agents at your dev server.

// vite.config.tsimport{defineConfig}from"vite";importcaptunfrom"captun/vite";exportdefaultdefineConfig({plugins: [captun()],});

vite dev then prints a public URL next to the local ones:

 ➜ Local: http://localhost:5173/
➜ Captun: https://abc123.captun.sh

The plugin is a thin wrapper around createCaptunTunnel: it waits for the server to start listening, opens a tunnel, and forwards every public request to the local server. All client tunnel options pass straight through, plus two plugin-level callbacks:

captun({// createCaptunTunnel optionsgateway: process.env.CAPTUN_GATEWAY,// Tunnel Gateway URL; defaults to the hosted captun.sh servicename: "my-app",// Tunnel Name used in the public URL; random when omittedtoken: process.env.CAPTUN_TOKEN,// Connect Token; random when omitted// plugin optionsonTunnel: ({ url, token })=>{// runs once the tunnel is connected, e.g. to register a webhook URL;// replaces the default "➜ Captun: <url>" log},onError: (error)=>{// runs when creating the tunnel fails; replaces the default error log// (which leaves the server running). Rethrow to make the failure fatal.},});

After npx captun deploy, point the plugin at your own self-hosted gateway by passing your deployment's gateway and token (for example via environment variables, as above). To only tunnel on demand, make the plugin conditional in your config:

plugins: [process.env.TUNNEL ? captun() : undefined],

Caveats: WebSockets are not forwarded, so Vite HMR only works on the local URL — the tunnel is for plain HTTP (webhooks, previews, e2e tests). For an https dev server, Node must trust the server's certificate (self-signed dev certificates will fail the local hop).

Advanced usage

The captun worker.ts implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in iterate/iterate). For example, here's a weather application which allows mocking its egress to the weather API:

import{DurableObject}from"cloudflare:workers";import{acceptFetcherCapability,typeFetcherStub}from"captun";typeWeatherReporterEnv=Env&{WEATHER_REPORTER_EGRESS: DurableObjectNamespace<WeatherReporterEgressTunnel>;};exportclassWeatherReporterEgressTunnelextendsDurableObject<WeatherReporterEnv>{privateegressFetcher: FetcherStub|undefined;asyncfetch(request: Request){consturl=newURL(request.url);if(url.pathname==="/weather"){// Here's the value our app provides: fetching and gorgeously formatting weather dataconstcity=url.searchParams.get("city");constresponse=awaitthis.egressFetch(`https://wttr.in/${city}?format=j1`);constweather=awaitresponse.json<{current_condition: [{temp_C: string}]}>();returnnewResponse(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`,);}if(url.pathname==="/__intercept-egress-traffic"){// Here we set up our worker to allow clients/tests to intercept egress trafficthis.egressFetcher?.[Symbol.dispose]();const{ response, fetcher }=acceptFetcherCapability({onDisconnect: ()=>{if(this.egressFetcher===fetcher)this.egressFetcher=undefined;},});this.egressFetcher=fetcher;queueMicrotask(()=>voidfetcher.ready({url: newURL(request.url).origin}));returnresponse;}returnnewResponse("Not found\n",{status: 404});}getegressFetch(): typeoffetch{if(this.egressFetcher){returnasync(input,init)=>this.egressFetcher!.fetch(newRequest(input,init));}returnfetch;}}exportdefault{fetch(request: Request,env: WeatherReporterEnv){returnenv.WEATHER_REPORTER_EGRESS.getByName("default").fetch(request);},}satisfiesExportedHandler<WeatherReporterEnv>;

The core client/server pieces (createCaptunTunnel, acceptFetcherCapability, acceptFetcherCapabilityFromSocket, Fetcher, and FetcherStub) live in src/index.ts — small TypeScript wrappers around Cap'n Web. For a self-hosted Cloudflare Tunnel Gateway, copy or adapt src/server/worker.ts and the Durable Object binding in wrangler.jsonc. The Iterate-operated hosted service is separate: its product surface lives under src/hosted, with wrangler.hosted.jsonc as its deployment config.

Runtime Adapters for accepting Fetcher Capabilities outside Cloudflare Workers are implemented under src/server and exported as captun/node, captun/bun, and captun/deno. See examples/node, examples/bun, and examples/deno for the same small weather egress test running in each runtime.

Advanced CLI Usage

The CLI is mostly focused on ngrok-style use-cases. Without local config it uses the hosted captun.sh service. Once you have run npx captun deploy, further commands will pick up your self-hosted gateway URL and token from your machine's captun config. You can also pass them explicitly (for example, to create a tunnel using a deployment created from someone else's machine):

npx captun 3000 --gateway 'https://captun.youraccount.workers.dev' --token abc123

By default, the npx captun 3000 command will generate a name for the tunnel it creates. You can customise this with --name:

npx captun 3000 --name my-very-serious-tunnel-name

By default the worker routes /my-tunnel/foo/bar to the capnweb session for "my-tunnel", and becomes a corresponding HTTP request with pathname /foo/bar when it reaches your client.

Custom domains

Running npx captun deploy interactively walks you through where the tunnel URLs should live. There are four options, and which one is best for you depends on the kind of apps you want to tunnel to and whether you already have a domain on Cloudflare.

Routing is controlled by a single Worker env var, CUSTOM_HOSTNAME. When unset (workers.dev deploys), tunnels use folder routing: the first path segment is the tunnel name. When set (custom-domain deploys), tunnels use subdomain routing — the last DNS label before CUSTOM_HOSTNAME is the tunnel name, and anything to the left of it is ignored. The deploy wizard sets CUSTOM_HOSTNAME for you; the parsing logic lives in getTunnelNameFromUrl in src/server/tunnel-addressing.ts.

1. <tunnel>.<account>.workers.dev/<tunnel-name> (default)

Free, instant, no DNS setup. The tunnel URLs look like https://captun.<account>.workers.dev/demo and your app runs under the /demo path prefix.

Pick this if: you want the fastest possible setup, and the apps you're tunneling to are happy under a path prefix.

Caveat: apps that assume they live at / will misbehave — absolute redirects to /login, OAuth callbacks hardcoded to a root URL, cookies scoped to Path=/, and similar. If you hit any of those, pick one of the options below.

2. <tunnel>.your-domain.com (free wildcard on an existing zone)

Free, instant. Tunnel URLs become https://demo.your-domain.com/ — apps see a naked hostname, so path-prefix issues from option 1 disappear. Universal SSL covers first-level subdomains so no cert work is needed.

npx captun deploy --route '*.your-domain.com/*' --zone your-domain.com

Pick this if: you have a Cloudflare-managed domain you can dedicate to tunnels.

Caveat: the worker route *.your-domain.com/* will catch every otherwise-unrouted subdomain on this zone, which means you should only use this on a domain you've actually set aside for tunnels. Don't point it at your main production domain.

3. <tunnel>.captun.your-domain.com (requires Advanced Certificate Manager)

Tunnels are namespaced under captun. on your existing domain (or whatever subdomain prefix you pick in the wizard), so the rest of the zone is unaffected.

npx captun deploy --route '*.captun.your-domain.com/*' --zone your-domain.com

Universal SSL only covers the apex and first-level subdomains, so *.captun.your-domain.com (a second-level wildcard) needs a separately-ordered certificate. The wizard handles this by ordering an Advanced Certificate Manager certificate pack for *.captun.your-domain.com + captun.your-domain.com and waiting for it to become active.

Pick this if: you want clean naming on an existing domain without the foot-gun of option 2.

Caveat: ACM is $10/month per zone. The wizard checks whether ACM is already enabled and bails with a link to the dashboard if it isn't — there's no way to subscribe to ACM via API.

4. Dedicated tunnel domain

If you don't have a suitable Cloudflare-managed domain, registering a throwaway one (e.g. my-tunnels.com) and using it with option 2 ends up cheaper than enabling ACM for option 3 (~$9/year versus $10/month).

  1. Register a domain via Cloudflare Registrar or any third-party registrar.
  2. Add the domain to your Cloudflare account and wait for the zone to become active.
  3. Re-run captun deploy and pick option 2 for the new zone.

Sharding

By default, all tunnel names live in one warm CaptunServerShard Durable Object. That minimizes cold-start latency. Use --shards only when you need more aggregate throughput for many concurrent large responses:

npx captun deploy --shards 256

All of captun's public API (both the client createCaptunTunnel and the server-side acceptFetcherCapability) is exported from the single captun entry point. acceptFetcherCapabilityFromSocket(socket) is also exported for Workers that have already performed the WebSocket upgrade themselves.

Performance

On May 18, 2026 from London, one warm-shard Captun tunnel reached first fetch in 188ms p50. Rechecking provider startup on the same day showed ngrok was much faster than the earlier sample: one ngrok ad-hoc tunnel reached 451ms, and 10 concurrent ngrok tunnels reached 658ms p50. Cloudflared quick tunnels still took about 8.5-9s when successful because the trycloudflare.com hostname was printed several seconds before DNS/public routing was ready.

Ad-hoc tunnelFirst fetch
captun188ms
ngrok451ms (+140%)
cloudflared quick tunnel8.51s (+4,427%)
10 concurrent ad-hoc tunnelsSuccessfulp50p90p99
captun10/10172ms186ms189ms
ngrok10/10658ms (+283%)695ms (+274%)985ms (+421%)
cloudflared quick tunnel2/108.89s (+5,069%)9.00s (+4,739%)9.00s (+4,662%)

One shard is the default because it spins up fastest. More shards trade extra cold starts for more total throughput: 100 concurrent 2MiB streams through one shard took 26.34s p50, while 150 concurrent 2MiB streams spread over 256 warmed shards took 9.76s p50.

Captun startup chart

The scripts used for these numbers are scripts/benchmark-startup.ts and scripts/benchmark-streams.ts; the compact recorded results are in docs/performance, with notes in docs/benchmarks.md.

For test and development traffic, this should usually cost effectively nothing on Cloudflare: the Workers Free plan includes daily Worker requests, and Durable Objects have their own included free usage. Check pricing before serious volume, because connected Durable Objects cannot hibernate while the WebSocket is open.

How Does It Work?

We just pass fetch() through fetch(). No, really.

With Cap'n Web, the Node client opens a WebSocket RPC session to the Worker and exposes its local fetcher as the session's main capability. The Worker's tunnel handle is a stub for that capability, whose only interesting method is fetch(request). From then on, the Worker can forward public HTTP requests to that function and return the resulting Response.

All you need is fetch(). Requests, responses, headers, bodies, streams, SSE, and uploads are already web standards; this is the web-standards way this should work.

sequenceDiagram
participant HTTP as HTTP client
participant Gateway as Tunnel Gateway / CaptunServerShard
participant Client as Node client
Client->>Gateway: WebSocket RPC connect to ?captun-connect=1&captun-name=demo with fetcher as main capability
Gateway-->>Client: ready({ url })
HTTP->>Gateway: GET /demo/report
Gateway->>Client: fetch(request)
Client-->>Gateway: Response
Gateway-->>HTTP: Response
Loading

See examples/weather-reporter for a small workspace package that imports captun and has its own e2e tests.

Development

The Worker needs the CaptunServerShard Durable Object binding and migration from wrangler.jsonc. For local development:

pnpm install
pnpm run build
pnpm run dev

Run tests with pnpm test. The root e2e suite uses Miniflare by default; set CAPTUN_GATEWAY, with optional CAPTUN_TOKEN, to run the same cases against a deployed Worker.

End-to-end smoke tests for build, dry-run deploy, local wrangler dev, tunnel, and curl live in scripts/smoke/ with documentation in docs/smoke-test.md:

pnpm smoke
./scripts/smoke-test.sh list
./scripts/smoke-test.sh step-5-tunnel-local

Caveats

Captun is intentionally small. It is a reference implementation you can copy into a Worker or Durable Object, not a managed tunnel product.

It is fast but less durable than Cloudflare Tunnel. There is no redundant connection in another data center, and a connected Durable Object can still be restarted, so an in-flight request can fail.

Large binary streams are slower than small requests because a Response body crosses the Cap'n Web WebSocket/RPC session rather than getting spliced as a native HTTP socket. For webhook callbacks, mocked internet egress, local previews, and e2e tests, that tradeoff is usually fine.

Connecting a second client with the same tunnel name replaces the previous connection. Malformed percent-encoding in a folder tunnel name is rejected as a missing tunnel name.

About

Minimal Cap'n Web tunnel for Cloudflare Workers and Node

Resources

Stars

23 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Captun (cap[nweb] tun[nel])

Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a Node process over Cap'n Web.

Quick start

Expose a local HTTP server with the hosted captun.sh tunnel service:

npx captun 3000

That prints a public URL like https://abc123.captun.sh and forwards requests to localhost:3000.

If you want your own tunnel server, deploy a captun Worker to your Cloudflare account. You can think of this like your own personal ngrok server, but faster:

deploy expects Cloudflare auth to already be available. Run npx wrangler login once, or set CLOUDFLARE_API_TOKEN for CI and other non-interactive shells.

npx captun deploy

The deploy command uses wrangler under the hood to deploy an opinionated captun Tunnel Gateway to your Cloudflare account, then stores its gateway URL and token in an XDG config file for later tunnel commands.

Programmatic usage

You can use the hosted service from code for receiving HTTP requests. First npm install captun to add it as a dependency. Then create it:

import{createCaptunTunnel}from"captun";consttunnel=awaitcreateCaptunTunnel({fetch: async(request)=>{consturl=newURL(request.url);if(url.pathname.endsWith("/webhook")){console.log("Received a webhook:",awaitrequest.json());returnResponse.json({ok: true});}returnnewResponse("not found",{status: 404});},});console.log(`Listening to webhooks on ${tunnel.url}/webhook`);awaitnewPromise(()=>{});// stay alive until killed

That's all you need! No local ports, just a fetch function.

WebSockets

Tunnels forward WebSockets too: npx captun 3000 exposes any local WebSocket server (socket.io, ws, Bun, Deno, ...) with handshake headers, subprotocols, binary messages, and close codes passing through. In code, a fetch handler accepts WebSockets Workers-style on any runtime:

import{createCaptunTunnel,createWebSocketResponse,isWebSocketUpgradeRequest,WebSocketPair,}from"captun";awaitcreateCaptunTunnel({fetch(request){if(!isWebSocketUpgradeRequest(request))returnnewResponse("hello");constpair=newWebSocketPair();pair[1].accept();pair[1].addEventListener("message",(event)=>pair[1].send(`echo:${event.data}`));returncreateWebSocketResponse(pair[0]);},});

Connections are relayed message by message over the tunnel, so ping/pong and compression are per-hop, close codes outside 1000/3000–4999 degrade to a plain close, and messages are capped at 16MiB so one oversized frame can't take down the tunnel.

Vite plugin

captun/vite serves your Vite dev server (and vite preview) through a public tunnel URL — handy for receiving webhooks against local code, sharing work in progress, or pointing remote devices and agents at your dev server.

// vite.config.tsimport{defineConfig}from"vite";importcaptunfrom"captun/vite";exportdefaultdefineConfig({plugins: [captun()],});

vite dev then prints a public URL next to the local ones:

 ➜ Local: http://localhost:5173/
➜ Captun: https://abc123.captun.sh

The plugin is a thin wrapper around createCaptunTunnel: it waits for the server to start listening, opens a tunnel, and forwards every public request to the local server. All client tunnel options pass straight through, plus two plugin-level callbacks:

captun({// createCaptunTunnel optionsgateway: process.env.CAPTUN_GATEWAY,// Tunnel Gateway URL; defaults to the hosted captun.sh servicename: "my-app",// Tunnel Name used in the public URL; random when omittedtoken: process.env.CAPTUN_TOKEN,// Connect Token; random when omitted// plugin optionsonTunnel: ({ url, token })=>{// runs once the tunnel is connected, e.g. to register a webhook URL;// replaces the default "➜ Captun: <url>" log},onError: (error)=>{// runs when creating the tunnel fails; replaces the default error log// (which leaves the server running). Rethrow to make the failure fatal.},});

After npx captun deploy, point the plugin at your own self-hosted gateway by passing your deployment's gateway and token (for example via environment variables, as above). To only tunnel on demand, make the plugin conditional in your config:

plugins: [process.env.TUNNEL ? captun() : undefined],

Caveats: WebSockets are not forwarded, so Vite HMR only works on the local URL — the tunnel is for plain HTTP (webhooks, previews, e2e tests). For an https dev server, Node must trust the server's certificate (self-signed dev certificates will fail the local hop).

Advanced usage

The captun worker.ts implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in iterate/iterate). For example, here's a weather application which allows mocking its egress to the weather API:

import{DurableObject}from"cloudflare:workers";import{acceptFetcherCapability,typeFetcherStub}from"captun";typeWeatherReporterEnv=Env&{WEATHER_REPORTER_EGRESS: DurableObjectNamespace<WeatherReporterEgressTunnel>;};exportclassWeatherReporterEgressTunnelextendsDurableObject<WeatherReporterEnv>{privateegressFetcher: FetcherStub|undefined;asyncfetch(request: Request){consturl=newURL(request.url);if(url.pathname==="/weather"){// Here's the value our app provides: fetching and gorgeously formatting weather dataconstcity=url.searchParams.get("city");constresponse=awaitthis.egressFetch(`https://wttr.in/${city}?format=j1`);constweather=awaitresponse.json<{current_condition: [{temp_C: string}]}>();returnnewResponse(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`,);}if(url.pathname==="/__intercept-egress-traffic"){// Here we set up our worker to allow clients/tests to intercept egress trafficthis.egressFetcher?.[Symbol.dispose]();const{ response, fetcher }=acceptFetcherCapability({onDisconnect: ()=>{if(this.egressFetcher===fetcher)this.egressFetcher=undefined;},});this.egressFetcher=fetcher;queueMicrotask(()=>voidfetcher.ready({url: newURL(request.url).origin}));returnresponse;}returnnewResponse("Not found\n",{status: 404});}getegressFetch(): typeoffetch{if(this.egressFetcher){returnasync(input,init)=>this.egressFetcher!.fetch(newRequest(input,init));}returnfetch;}}exportdefault{fetch(request: Request,env: WeatherReporterEnv){returnenv.WEATHER_REPORTER_EGRESS.getByName("default").fetch(request);},}satisfiesExportedHandler<WeatherReporterEnv>;

The core client/server pieces (createCaptunTunnel, acceptFetcherCapability, acceptFetcherCapabilityFromSocket, Fetcher, and FetcherStub) live in src/index.ts — small TypeScript wrappers around Cap'n Web. For a self-hosted Cloudflare Tunnel Gateway, copy or adapt src/server/worker.ts and the Durable Object binding in wrangler.jsonc. The Iterate-operated hosted service is separate: its product surface lives under src/hosted, with wrangler.hosted.jsonc as its deployment config.

Runtime Adapters for accepting Fetcher Capabilities outside Cloudflare Workers are implemented under src/server and exported as captun/node, captun/bun, and captun/deno. See examples/node, examples/bun, and examples/deno for the same small weather egress test running in each runtime.

Advanced CLI Usage

The CLI is mostly focused on ngrok-style use-cases. Without local config it uses the hosted captun.sh service. Once you have run npx captun deploy, further commands will pick up your self-hosted gateway URL and token from your machine's captun config. You can also pass them explicitly (for example, to create a tunnel using a deployment created from someone else's machine):

npx captun 3000 --gateway 'https://captun.youraccount.workers.dev' --token abc123

By default, the npx captun 3000 command will generate a name for the tunnel it creates. You can customise this with --name:

npx captun 3000 --name my-very-serious-tunnel-name

By default the worker routes /my-tunnel/foo/bar to the capnweb session for "my-tunnel", and becomes a corresponding HTTP request with pathname /foo/bar when it reaches your client.

Custom domains

Running npx captun deploy interactively walks you through where the tunnel URLs should live. There are four options, and which one is best for you depends on the kind of apps you want to tunnel to and whether you already have a domain on Cloudflare.

Routing is controlled by a single Worker env var, CUSTOM_HOSTNAME. When unset (workers.dev deploys), tunnels use folder routing: the first path segment is the tunnel name. When set (custom-domain deploys), tunnels use subdomain routing — the last DNS label before CUSTOM_HOSTNAME is the tunnel name, and anything to the left of it is ignored. The deploy wizard sets CUSTOM_HOSTNAME for you; the parsing logic lives in getTunnelNameFromUrl in src/server/tunnel-addressing.ts.

1. <tunnel>.<account>.workers.dev/<tunnel-name> (default)

Free, instant, no DNS setup. The tunnel URLs look like https://captun.<account>.workers.dev/demo and your app runs under the /demo path prefix.

Pick this if: you want the fastest possible setup, and the apps you're tunneling to are happy under a path prefix.

Caveat: apps that assume they live at / will misbehave — absolute redirects to /login, OAuth callbacks hardcoded to a root URL, cookies scoped to Path=/, and similar. If you hit any of those, pick one of the options below.

2. <tunnel>.your-domain.com (free wildcard on an existing zone)

Free, instant. Tunnel URLs become https://demo.your-domain.com/ — apps see a naked hostname, so path-prefix issues from option 1 disappear. Universal SSL covers first-level subdomains so no cert work is needed.

npx captun deploy --route '*.your-domain.com/*' --zone your-domain.com

Pick this if: you have a Cloudflare-managed domain you can dedicate to tunnels.

Caveat: the worker route *.your-domain.com/* will catch every otherwise-unrouted subdomain on this zone, which means you should only use this on a domain you've actually set aside for tunnels. Don't point it at your main production domain.

3. <tunnel>.captun.your-domain.com (requires Advanced Certificate Manager)

Tunnels are namespaced under captun. on your existing domain (or whatever subdomain prefix you pick in the wizard), so the rest of the zone is unaffected.

npx captun deploy --route '*.captun.your-domain.com/*' --zone your-domain.com

Universal SSL only covers the apex and first-level subdomains, so *.captun.your-domain.com (a second-level wildcard) needs a separately-ordered certificate. The wizard handles this by ordering an Advanced Certificate Manager certificate pack for *.captun.your-domain.com + captun.your-domain.com and waiting for it to become active.

Pick this if: you want clean naming on an existing domain without the foot-gun of option 2.

Caveat: ACM is $10/month per zone. The wizard checks whether ACM is already enabled and bails with a link to the dashboard if it isn't — there's no way to subscribe to ACM via API.

4. Dedicated tunnel domain

If you don't have a suitable Cloudflare-managed domain, registering a throwaway one (e.g. my-tunnels.com) and using it with option 2 ends up cheaper than enabling ACM for option 3 (~$9/year versus $10/month).

  1. Register a domain via Cloudflare Registrar or any third-party registrar.
  2. Add the domain to your Cloudflare account and wait for the zone to become active.
  3. Re-run captun deploy and pick option 2 for the new zone.

Sharding

By default, all tunnel names live in one warm CaptunServerShard Durable Object. That minimizes cold-start latency. Use --shards only when you need more aggregate throughput for many concurrent large responses:

npx captun deploy --shards 256

All of captun's public API (both the client createCaptunTunnel and the server-side acceptFetcherCapability) is exported from the single captun entry point. acceptFetcherCapabilityFromSocket(socket) is also exported for Workers that have already performed the WebSocket upgrade themselves.

Performance

On May 18, 2026 from London, one warm-shard Captun tunnel reached first fetch in 188ms p50. Rechecking provider startup on the same day showed ngrok was much faster than the earlier sample: one ngrok ad-hoc tunnel reached 451ms, and 10 concurrent ngrok tunnels reached 658ms p50. Cloudflared quick tunnels still took about 8.5-9s when successful because the trycloudflare.com hostname was printed several seconds before DNS/public routing was ready.

Ad-hoc tunnelFirst fetch
captun188ms
ngrok451ms (+140%)
cloudflared quick tunnel8.51s (+4,427%)
10 concurrent ad-hoc tunnelsSuccessfulp50p90p99
captun10/10172ms186ms189ms
ngrok10/10658ms (+283%)695ms (+274%)985ms (+421%)
cloudflared quick tunnel2/108.89s (+5,069%)9.00s (+4,739%)9.00s (+4,662%)

One shard is the default because it spins up fastest. More shards trade extra cold starts for more total throughput: 100 concurrent 2MiB streams through one shard took 26.34s p50, while 150 concurrent 2MiB streams spread over 256 warmed shards took 9.76s p50.

Captun startup chart

The scripts used for these numbers are scripts/benchmark-startup.ts and scripts/benchmark-streams.ts; the compact recorded results are in docs/performance, with notes in docs/benchmarks.md.

For test and development traffic, this should usually cost effectively nothing on Cloudflare: the Workers Free plan includes daily Worker requests, and Durable Objects have their own included free usage. Check pricing before serious volume, because connected Durable Objects cannot hibernate while the WebSocket is open.

How Does It Work?

We just pass fetch() through fetch(). No, really.

With Cap'n Web, the Node client opens a WebSocket RPC session to the Worker and exposes its local fetcher as the session's main capability. The Worker's tunnel handle is a stub for that capability, whose only interesting method is fetch(request). From then on, the Worker can forward public HTTP requests to that function and return the resulting Response.

All you need is fetch(). Requests, responses, headers, bodies, streams, SSE, and uploads are already web standards; this is the web-standards way this should work.

sequenceDiagram
participant HTTP as HTTP client
participant Gateway as Tunnel Gateway / CaptunServerShard
participant Client as Node client
Client->>Gateway: WebSocket RPC connect to ?captun-connect=1&captun-name=demo with fetcher as main capability
Gateway-->>Client: ready({ url })
HTTP->>Gateway: GET /demo/report
Gateway->>Client: fetch(request)
Client-->>Gateway: Response
Gateway-->>HTTP: Response
Loading

See examples/weather-reporter for a small workspace package that imports captun and has its own e2e tests.

Development

The Worker needs the CaptunServerShard Durable Object binding and migration from wrangler.jsonc. For local development:

pnpm install
pnpm run build
pnpm run dev

Run tests with pnpm test. The root e2e suite uses Miniflare by default; set CAPTUN_GATEWAY, with optional CAPTUN_TOKEN, to run the same cases against a deployed Worker.

End-to-end smoke tests for build, dry-run deploy, local wrangler dev, tunnel, and curl live in scripts/smoke/ with documentation in docs/smoke-test.md:

pnpm smoke
./scripts/smoke-test.sh list
./scripts/smoke-test.sh step-5-tunnel-local

Caveats

Captun is intentionally small. It is a reference implementation you can copy into a Worker or Durable Object, not a managed tunnel product.

It is fast but less durable than Cloudflare Tunnel. There is no redundant connection in another data center, and a connected Durable Object can still be restarted, so an in-flight request can fail.

Large binary streams are slower than small requests because a Response body crosses the Cap'n Web WebSocket/RPC session rather than getting spliced as a native HTTP socket. For webhook callbacks, mocked internet egress, local previews, and e2e tests, that tradeoff is usually fine.

Connecting a second client with the same tunnel name replaces the previous connection. Malformed percent-encoding in a folder tunnel name is rejected as a missing tunnel name.

About

Minimal Cap'n Web tunnel for Cloudflare Workers and Node

Resources

Stars

23 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Captun (cap[nweb] tun[nel])

Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a Node process over Cap'n Web.

Quick start

Expose a local HTTP server with the hosted captun.sh tunnel service:

npx captun 3000

That prints a public URL like https://abc123.captun.sh and forwards requests to localhost:3000.

If you want your own tunnel server, deploy a captun Worker to your Cloudflare account. You can think of this like your own personal ngrok server, but faster:

deploy expects Cloudflare auth to already be available. Run npx wrangler login once, or set CLOUDFLARE_API_TOKEN for CI and other non-interactive shells.

npx captun deploy

The deploy command uses wrangler under the hood to deploy an opinionated captun Tunnel Gateway to your Cloudflare account, then stores its gateway URL and token in an XDG config file for later tunnel commands.

Programmatic usage

You can use the hosted service from code for receiving HTTP requests. First npm install captun to add it as a dependency. Then create it:

import{createCaptunTunnel}from"captun";consttunnel=awaitcreateCaptunTunnel({fetch: async(request)=>{consturl=newURL(request.url);if(url.pathname.endsWith("/webhook")){console.log("Received a webhook:",awaitrequest.json());returnResponse.json({ok: true});}returnnewResponse("not found",{status: 404});},});console.log(`Listening to webhooks on ${tunnel.url}/webhook`);awaitnewPromise(()=>{});// stay alive until killed

That's all you need! No local ports, just a fetch function.

WebSockets

Tunnels forward WebSockets too: npx captun 3000 exposes any local WebSocket server (socket.io, ws, Bun, Deno, ...) with handshake headers, subprotocols, binary messages, and close codes passing through. In code, a fetch handler accepts WebSockets Workers-style on any runtime:

import{createCaptunTunnel,createWebSocketResponse,isWebSocketUpgradeRequest,WebSocketPair,}from"captun";awaitcreateCaptunTunnel({fetch(request){if(!isWebSocketUpgradeRequest(request))returnnewResponse("hello");constpair=newWebSocketPair();pair[1].accept();pair[1].addEventListener("message",(event)=>pair[1].send(`echo:${event.data}`));returncreateWebSocketResponse(pair[0]);},});

Connections are relayed message by message over the tunnel, so ping/pong and compression are per-hop, close codes outside 1000/3000–4999 degrade to a plain close, and messages are capped at 16MiB so one oversized frame can't take down the tunnel.

Vite plugin

captun/vite serves your Vite dev server (and vite preview) through a public tunnel URL — handy for receiving webhooks against local code, sharing work in progress, or pointing remote devices and agents at your dev server.

// vite.config.tsimport{defineConfig}from"vite";importcaptunfrom"captun/vite";exportdefaultdefineConfig({plugins: [captun()],});

vite dev then prints a public URL next to the local ones:

 ➜ Local: http://localhost:5173/
➜ Captun: https://abc123.captun.sh

The plugin is a thin wrapper around createCaptunTunnel: it waits for the server to start listening, opens a tunnel, and forwards every public request to the local server. All client tunnel options pass straight through, plus two plugin-level callbacks:

captun({// createCaptunTunnel optionsgateway: process.env.CAPTUN_GATEWAY,// Tunnel Gateway URL; defaults to the hosted captun.sh servicename: "my-app",// Tunnel Name used in the public URL; random when omittedtoken: process.env.CAPTUN_TOKEN,// Connect Token; random when omitted// plugin optionsonTunnel: ({ url, token })=>{// runs once the tunnel is connected, e.g. to register a webhook URL;// replaces the default "➜ Captun: <url>" log},onError: (error)=>{// runs when creating the tunnel fails; replaces the default error log// (which leaves the server running). Rethrow to make the failure fatal.},});

After npx captun deploy, point the plugin at your own self-hosted gateway by passing your deployment's gateway and token (for example via environment variables, as above). To only tunnel on demand, make the plugin conditional in your config:

plugins: [process.env.TUNNEL ? captun() : undefined],

Caveats: WebSockets are not forwarded, so Vite HMR only works on the local URL — the tunnel is for plain HTTP (webhooks, previews, e2e tests). For an https dev server, Node must trust the server's certificate (self-signed dev certificates will fail the local hop).

Advanced usage

The captun worker.ts implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in iterate/iterate). For example, here's a weather application which allows mocking its egress to the weather API:

import{DurableObject}from"cloudflare:workers";import{acceptFetcherCapability,typeFetcherStub}from"captun";typeWeatherReporterEnv=Env&{WEATHER_REPORTER_EGRESS: DurableObjectNamespace<WeatherReporterEgressTunnel>;};exportclassWeatherReporterEgressTunnelextendsDurableObject<WeatherReporterEnv>{privateegressFetcher: FetcherStub|undefined;asyncfetch(request: Request){consturl=newURL(request.url);if(url.pathname==="/weather"){// Here's the value our app provides: fetching and gorgeously formatting weather dataconstcity=url.searchParams.get("city");constresponse=awaitthis.egressFetch(`https://wttr.in/${city}?format=j1`);constweather=awaitresponse.json<{current_condition: [{temp_C: string}]}>();returnnewResponse(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`,);}if(url.pathname==="/__intercept-egress-traffic"){// Here we set up our worker to allow clients/tests to intercept egress trafficthis.egressFetcher?.[Symbol.dispose]();const{ response, fetcher }=acceptFetcherCapability({onDisconnect: ()=>{if(this.egressFetcher===fetcher)this.egressFetcher=undefined;},});this.egressFetcher=fetcher;queueMicrotask(()=>voidfetcher.ready({url: newURL(request.url).origin}));returnresponse;}returnnewResponse("Not found\n",{status: 404});}getegressFetch(): typeoffetch{if(this.egressFetcher){returnasync(input,init)=>this.egressFetcher!.fetch(newRequest(input,init));}returnfetch;}}exportdefault{fetch(request: Request,env: WeatherReporterEnv){returnenv.WEATHER_REPORTER_EGRESS.getByName("default").fetch(request);},}satisfiesExportedHandler<WeatherReporterEnv>;

The core client/server pieces (createCaptunTunnel, acceptFetcherCapability, acceptFetcherCapabilityFromSocket, Fetcher, and FetcherStub) live in src/index.ts — small TypeScript wrappers around Cap'n Web. For a self-hosted Cloudflare Tunnel Gateway, copy or adapt src/server/worker.ts and the Durable Object binding in wrangler.jsonc. The Iterate-operated hosted service is separate: its product surface lives under src/hosted, with wrangler.hosted.jsonc as its deployment config.

Runtime Adapters for accepting Fetcher Capabilities outside Cloudflare Workers are implemented under src/server and exported as captun/node, captun/bun, and captun/deno. See examples/node, examples/bun, and examples/deno for the same small weather egress test running in each runtime.

Advanced CLI Usage

The CLI is mostly focused on ngrok-style use-cases. Without local config it uses the hosted captun.sh service. Once you have run npx captun deploy, further commands will pick up your self-hosted gateway URL and token from your machine's captun config. You can also pass them explicitly (for example, to create a tunnel using a deployment created from someone else's machine):

npx captun 3000 --gateway 'https://captun.youraccount.workers.dev' --token abc123

By default, the npx captun 3000 command will generate a name for the tunnel it creates. You can customise this with --name:

npx captun 3000 --name my-very-serious-tunnel-name

By default the worker routes /my-tunnel/foo/bar to the capnweb session for "my-tunnel", and becomes a corresponding HTTP request with pathname /foo/bar when it reaches your client.

Custom domains

Running npx captun deploy interactively walks you through where the tunnel URLs should live. There are four options, and which one is best for you depends on the kind of apps you want to tunnel to and whether you already have a domain on Cloudflare.

Routing is controlled by a single Worker env var, CUSTOM_HOSTNAME. When unset (workers.dev deploys), tunnels use folder routing: the first path segment is the tunnel name. When set (custom-domain deploys), tunnels use subdomain routing — the last DNS label before CUSTOM_HOSTNAME is the tunnel name, and anything to the left of it is ignored. The deploy wizard sets CUSTOM_HOSTNAME for you; the parsing logic lives in getTunnelNameFromUrl in src/server/tunnel-addressing.ts.

1. <tunnel>.<account>.workers.dev/<tunnel-name> (default)

Free, instant, no DNS setup. The tunnel URLs look like https://captun.<account>.workers.dev/demo and your app runs under the /demo path prefix.

Pick this if: you want the fastest possible setup, and the apps you're tunneling to are happy under a path prefix.

Caveat: apps that assume they live at / will misbehave — absolute redirects to /login, OAuth callbacks hardcoded to a root URL, cookies scoped to Path=/, and similar. If you hit any of those, pick one of the options below.

2. <tunnel>.your-domain.com (free wildcard on an existing zone)

Free, instant. Tunnel URLs become https://demo.your-domain.com/ — apps see a naked hostname, so path-prefix issues from option 1 disappear. Universal SSL covers first-level subdomains so no cert work is needed.

npx captun deploy --route '*.your-domain.com/*' --zone your-domain.com

Pick this if: you have a Cloudflare-managed domain you can dedicate to tunnels.

Caveat: the worker route *.your-domain.com/* will catch every otherwise-unrouted subdomain on this zone, which means you should only use this on a domain you've actually set aside for tunnels. Don't point it at your main production domain.

3. <tunnel>.captun.your-domain.com (requires Advanced Certificate Manager)

Tunnels are namespaced under captun. on your existing domain (or whatever subdomain prefix you pick in the wizard), so the rest of the zone is unaffected.

npx captun deploy --route '*.captun.your-domain.com/*' --zone your-domain.com

Universal SSL only covers the apex and first-level subdomains, so *.captun.your-domain.com (a second-level wildcard) needs a separately-ordered certificate. The wizard handles this by ordering an Advanced Certificate Manager certificate pack for *.captun.your-domain.com + captun.your-domain.com and waiting for it to become active.

Pick this if: you want clean naming on an existing domain without the foot-gun of option 2.

Caveat: ACM is $10/month per zone. The wizard checks whether ACM is already enabled and bails with a link to the dashboard if it isn't — there's no way to subscribe to ACM via API.

4. Dedicated tunnel domain

If you don't have a suitable Cloudflare-managed domain, registering a throwaway one (e.g. my-tunnels.com) and using it with option 2 ends up cheaper than enabling ACM for option 3 (~$9/year versus $10/month).

  1. Register a domain via Cloudflare Registrar or any third-party registrar.
  2. Add the domain to your Cloudflare account and wait for the zone to become active.
  3. Re-run captun deploy and pick option 2 for the new zone.

Sharding

By default, all tunnel names live in one warm CaptunServerShard Durable Object. That minimizes cold-start latency. Use --shards only when you need more aggregate throughput for many concurrent large responses:

npx captun deploy --shards 256

All of captun's public API (both the client createCaptunTunnel and the server-side acceptFetcherCapability) is exported from the single captun entry point. acceptFetcherCapabilityFromSocket(socket) is also exported for Workers that have already performed the WebSocket upgrade themselves.

Performance

On May 18, 2026 from London, one warm-shard Captun tunnel reached first fetch in 188ms p50. Rechecking provider startup on the same day showed ngrok was much faster than the earlier sample: one ngrok ad-hoc tunnel reached 451ms, and 10 concurrent ngrok tunnels reached 658ms p50. Cloudflared quick tunnels still took about 8.5-9s when successful because the trycloudflare.com hostname was printed several seconds before DNS/public routing was ready.

Ad-hoc tunnelFirst fetch
captun188ms
ngrok451ms (+140%)
cloudflared quick tunnel8.51s (+4,427%)
10 concurrent ad-hoc tunnelsSuccessfulp50p90p99
captun10/10172ms186ms189ms
ngrok10/10658ms (+283%)695ms (+274%)985ms (+421%)
cloudflared quick tunnel2/108.89s (+5,069%)9.00s (+4,739%)9.00s (+4,662%)

One shard is the default because it spins up fastest. More shards trade extra cold starts for more total throughput: 100 concurrent 2MiB streams through one shard took 26.34s p50, while 150 concurrent 2MiB streams spread over 256 warmed shards took 9.76s p50.

Captun startup chart

The scripts used for these numbers are scripts/benchmark-startup.ts and scripts/benchmark-streams.ts; the compact recorded results are in docs/performance, with notes in docs/benchmarks.md.

For test and development traffic, this should usually cost effectively nothing on Cloudflare: the Workers Free plan includes daily Worker requests, and Durable Objects have their own included free usage. Check pricing before serious volume, because connected Durable Objects cannot hibernate while the WebSocket is open.

How Does It Work?

We just pass fetch() through fetch(). No, really.

With Cap'n Web, the Node client opens a WebSocket RPC session to the Worker and exposes its local fetcher as the session's main capability. The Worker's tunnel handle is a stub for that capability, whose only interesting method is fetch(request). From then on, the Worker can forward public HTTP requests to that function and return the resulting Response.

All you need is fetch(). Requests, responses, headers, bodies, streams, SSE, and uploads are already web standards; this is the web-standards way this should work.

sequenceDiagram
participant HTTP as HTTP client
participant Gateway as Tunnel Gateway / CaptunServerShard
participant Client as Node client
Client->>Gateway: WebSocket RPC connect to ?captun-connect=1&captun-name=demo with fetcher as main capability
Gateway-->>Client: ready({ url })
HTTP->>Gateway: GET /demo/report
Gateway->>Client: fetch(request)
Client-->>Gateway: Response
Gateway-->>HTTP: Response
Loading

See examples/weather-reporter for a small workspace package that imports captun and has its own e2e tests.

Development

The Worker needs the CaptunServerShard Durable Object binding and migration from wrangler.jsonc. For local development:

pnpm install
pnpm run build
pnpm run dev

Run tests with pnpm test. The root e2e suite uses Miniflare by default; set CAPTUN_GATEWAY, with optional CAPTUN_TOKEN, to run the same cases against a deployed Worker.

End-to-end smoke tests for build, dry-run deploy, local wrangler dev, tunnel, and curl live in scripts/smoke/ with documentation in docs/smoke-test.md:

pnpm smoke
./scripts/smoke-test.sh list
./scripts/smoke-test.sh step-5-tunnel-local

Caveats

Captun is intentionally small. It is a reference implementation you can copy into a Worker or Durable Object, not a managed tunnel product.

It is fast but less durable than Cloudflare Tunnel. There is no redundant connection in another data center, and a connected Durable Object can still be restarted, so an in-flight request can fail.

Large binary streams are slower than small requests because a Response body crosses the Cap'n Web WebSocket/RPC session rather than getting spliced as a native HTTP socket. For webhook callbacks, mocked internet egress, local previews, and e2e tests, that tradeoff is usually fine.

Connecting a second client with the same tunnel name replaces the previous connection. Malformed percent-encoding in a folder tunnel name is rejected as a missing tunnel name.

About

Minimal Cap'n Web tunnel for Cloudflare Workers and Node

Resources

Stars

23 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Captun (cap[nweb] tun[nel])

Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a Node process over Cap'n Web.

Quick start

Expose a local HTTP server with the hosted captun.sh tunnel service:

npx captun 3000

That prints a public URL like https://abc123.captun.sh and forwards requests to localhost:3000.

If you want your own tunnel server, deploy a captun Worker to your Cloudflare account. You can think of this like your own personal ngrok server, but faster:

deploy expects Cloudflare auth to already be available. Run npx wrangler login once, or set CLOUDFLARE_API_TOKEN for CI and other non-interactive shells.

npx captun deploy

The deploy command uses wrangler under the hood to deploy an opinionated captun Tunnel Gateway to your Cloudflare account, then stores its gateway URL and token in an XDG config file for later tunnel commands.

Programmatic usage

You can use the hosted service from code for receiving HTTP requests. First npm install captun to add it as a dependency. Then create it:

import{createCaptunTunnel}from"captun";consttunnel=awaitcreateCaptunTunnel({fetch: async(request)=>{consturl=newURL(request.url);if(url.pathname.endsWith("/webhook")){console.log("Received a webhook:",awaitrequest.json());returnResponse.json({ok: true});}returnnewResponse("not found",{status: 404});},});console.log(`Listening to webhooks on ${tunnel.url}/webhook`);awaitnewPromise(()=>{});// stay alive until killed

That's all you need! No local ports, just a fetch function.

WebSockets

Tunnels forward WebSockets too: npx captun 3000 exposes any local WebSocket server (socket.io, ws, Bun, Deno, ...) with handshake headers, subprotocols, binary messages, and close codes passing through. In code, a fetch handler accepts WebSockets Workers-style on any runtime:

import{createCaptunTunnel,createWebSocketResponse,isWebSocketUpgradeRequest,WebSocketPair,}from"captun";awaitcreateCaptunTunnel({fetch(request){if(!isWebSocketUpgradeRequest(request))returnnewResponse("hello");constpair=newWebSocketPair();pair[1].accept();pair[1].addEventListener("message",(event)=>pair[1].send(`echo:${event.data}`));returncreateWebSocketResponse(pair[0]);},});

Connections are relayed message by message over the tunnel, so ping/pong and compression are per-hop, close codes outside 1000/3000–4999 degrade to a plain close, and messages are capped at 16MiB so one oversized frame can't take down the tunnel.

Vite plugin

captun/vite serves your Vite dev server (and vite preview) through a public tunnel URL — handy for receiving webhooks against local code, sharing work in progress, or pointing remote devices and agents at your dev server.

// vite.config.tsimport{defineConfig}from"vite";importcaptunfrom"captun/vite";exportdefaultdefineConfig({plugins: [captun()],});

vite dev then prints a public URL next to the local ones:

 ➜ Local: http://localhost:5173/
➜ Captun: https://abc123.captun.sh

The plugin is a thin wrapper around createCaptunTunnel: it waits for the server to start listening, opens a tunnel, and forwards every public request to the local server. All client tunnel options pass straight through, plus two plugin-level callbacks:

captun({// createCaptunTunnel optionsgateway: process.env.CAPTUN_GATEWAY,// Tunnel Gateway URL; defaults to the hosted captun.sh servicename: "my-app",// Tunnel Name used in the public URL; random when omittedtoken: process.env.CAPTUN_TOKEN,// Connect Token; random when omitted// plugin optionsonTunnel: ({ url, token })=>{// runs once the tunnel is connected, e.g. to register a webhook URL;// replaces the default "➜ Captun: <url>" log},onError: (error)=>{// runs when creating the tunnel fails; replaces the default error log// (which leaves the server running). Rethrow to make the failure fatal.},});

After npx captun deploy, point the plugin at your own self-hosted gateway by passing your deployment's gateway and token (for example via environment variables, as above). To only tunnel on demand, make the plugin conditional in your config:

plugins: [process.env.TUNNEL ? captun() : undefined],

Caveats: WebSockets are not forwarded, so Vite HMR only works on the local URL — the tunnel is for plain HTTP (webhooks, previews, e2e tests). For an https dev server, Node must trust the server's certificate (self-signed dev certificates will fail the local hop).

Advanced usage

The captun worker.ts implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in iterate/iterate). For example, here's a weather application which allows mocking its egress to the weather API:

import{DurableObject}from"cloudflare:workers";import{acceptFetcherCapability,typeFetcherStub}from"captun";typeWeatherReporterEnv=Env&{WEATHER_REPORTER_EGRESS: DurableObjectNamespace<WeatherReporterEgressTunnel>;};exportclassWeatherReporterEgressTunnelextendsDurableObject<WeatherReporterEnv>{privateegressFetcher: FetcherStub|undefined;asyncfetch(request: Request){consturl=newURL(request.url);if(url.pathname==="/weather"){// Here's the value our app provides: fetching and gorgeously formatting weather dataconstcity=url.searchParams.get("city");constresponse=awaitthis.egressFetch(`https://wttr.in/${city}?format=j1`);constweather=awaitresponse.json<{current_condition: [{temp_C: string}]}>();returnnewResponse(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`,);}if(url.pathname==="/__intercept-egress-traffic"){// Here we set up our worker to allow clients/tests to intercept egress trafficthis.egressFetcher?.[Symbol.dispose]();const{ response, fetcher }=acceptFetcherCapability({onDisconnect: ()=>{if(this.egressFetcher===fetcher)this.egressFetcher=undefined;},});this.egressFetcher=fetcher;queueMicrotask(()=>voidfetcher.ready({url: newURL(request.url).origin}));returnresponse;}returnnewResponse("Not found\n",{status: 404});}getegressFetch(): typeoffetch{if(this.egressFetcher){returnasync(input,init)=>this.egressFetcher!.fetch(newRequest(input,init));}returnfetch;}}exportdefault{fetch(request: Request,env: WeatherReporterEnv){returnenv.WEATHER_REPORTER_EGRESS.getByName("default").fetch(request);},}satisfiesExportedHandler<WeatherReporterEnv>;

The core client/server pieces (createCaptunTunnel, acceptFetcherCapability, acceptFetcherCapabilityFromSocket, Fetcher, and FetcherStub) live in src/index.ts — small TypeScript wrappers around Cap'n Web. For a self-hosted Cloudflare Tunnel Gateway, copy or adapt src/server/worker.ts and the Durable Object binding in wrangler.jsonc. The Iterate-operated hosted service is separate: its product surface lives under src/hosted, with wrangler.hosted.jsonc as its deployment config.

Runtime Adapters for accepting Fetcher Capabilities outside Cloudflare Workers are implemented under src/server and exported as captun/node, captun/bun, and captun/deno. See examples/node, examples/bun, and examples/deno for the same small weather egress test running in each runtime.

Advanced CLI Usage

The CLI is mostly focused on ngrok-style use-cases. Without local config it uses the hosted captun.sh service. Once you have run npx captun deploy, further commands will pick up your self-hosted gateway URL and token from your machine's captun config. You can also pass them explicitly (for example, to create a tunnel using a deployment created from someone else's machine):

npx captun 3000 --gateway 'https://captun.youraccount.workers.dev' --token abc123

By default, the npx captun 3000 command will generate a name for the tunnel it creates. You can customise this with --name:

npx captun 3000 --name my-very-serious-tunnel-name

By default the worker routes /my-tunnel/foo/bar to the capnweb session for "my-tunnel", and becomes a corresponding HTTP request with pathname /foo/bar when it reaches your client.

Custom domains

Running npx captun deploy interactively walks you through where the tunnel URLs should live. There are four options, and which one is best for you depends on the kind of apps you want to tunnel to and whether you already have a domain on Cloudflare.

Routing is controlled by a single Worker env var, CUSTOM_HOSTNAME. When unset (workers.dev deploys), tunnels use folder routing: the first path segment is the tunnel name. When set (custom-domain deploys), tunnels use subdomain routing — the last DNS label before CUSTOM_HOSTNAME is the tunnel name, and anything to the left of it is ignored. The deploy wizard sets CUSTOM_HOSTNAME for you; the parsing logic lives in getTunnelNameFromUrl in src/server/tunnel-addressing.ts.

1. <tunnel>.<account>.workers.dev/<tunnel-name> (default)

Free, instant, no DNS setup. The tunnel URLs look like https://captun.<account>.workers.dev/demo and your app runs under the /demo path prefix.

Pick this if: you want the fastest possible setup, and the apps you're tunneling to are happy under a path prefix.

Caveat: apps that assume they live at / will misbehave — absolute redirects to /login, OAuth callbacks hardcoded to a root URL, cookies scoped to Path=/, and similar. If you hit any of those, pick one of the options below.

2. <tunnel>.your-domain.com (free wildcard on an existing zone)

Free, instant. Tunnel URLs become https://demo.your-domain.com/ — apps see a naked hostname, so path-prefix issues from option 1 disappear. Universal SSL covers first-level subdomains so no cert work is needed.

npx captun deploy --route '*.your-domain.com/*' --zone your-domain.com

Pick this if: you have a Cloudflare-managed domain you can dedicate to tunnels.

Caveat: the worker route *.your-domain.com/* will catch every otherwise-unrouted subdomain on this zone, which means you should only use this on a domain you've actually set aside for tunnels. Don't point it at your main production domain.

3. <tunnel>.captun.your-domain.com (requires Advanced Certificate Manager)

Tunnels are namespaced under captun. on your existing domain (or whatever subdomain prefix you pick in the wizard), so the rest of the zone is unaffected.

npx captun deploy --route '*.captun.your-domain.com/*' --zone your-domain.com

Universal SSL only covers the apex and first-level subdomains, so *.captun.your-domain.com (a second-level wildcard) needs a separately-ordered certificate. The wizard handles this by ordering an Advanced Certificate Manager certificate pack for *.captun.your-domain.com + captun.your-domain.com and waiting for it to become active.

Pick this if: you want clean naming on an existing domain without the foot-gun of option 2.

Caveat: ACM is $10/month per zone. The wizard checks whether ACM is already enabled and bails with a link to the dashboard if it isn't — there's no way to subscribe to ACM via API.

4. Dedicated tunnel domain

If you don't have a suitable Cloudflare-managed domain, registering a throwaway one (e.g. my-tunnels.com) and using it with option 2 ends up cheaper than enabling ACM for option 3 (~$9/year versus $10/month).

  1. Register a domain via Cloudflare Registrar or any third-party registrar.
  2. Add the domain to your Cloudflare account and wait for the zone to become active.
  3. Re-run captun deploy and pick option 2 for the new zone.

Sharding

By default, all tunnel names live in one warm CaptunServerShard Durable Object. That minimizes cold-start latency. Use --shards only when you need more aggregate throughput for many concurrent large responses:

npx captun deploy --shards 256

All of captun's public API (both the client createCaptunTunnel and the server-side acceptFetcherCapability) is exported from the single captun entry point. acceptFetcherCapabilityFromSocket(socket) is also exported for Workers that have already performed the WebSocket upgrade themselves.

Performance

On May 18, 2026 from London, one warm-shard Captun tunnel reached first fetch in 188ms p50. Rechecking provider startup on the same day showed ngrok was much faster than the earlier sample: one ngrok ad-hoc tunnel reached 451ms, and 10 concurrent ngrok tunnels reached 658ms p50. Cloudflared quick tunnels still took about 8.5-9s when successful because the trycloudflare.com hostname was printed several seconds before DNS/public routing was ready.

Ad-hoc tunnelFirst fetch
captun188ms
ngrok451ms (+140%)
cloudflared quick tunnel8.51s (+4,427%)
10 concurrent ad-hoc tunnelsSuccessfulp50p90p99
captun10/10172ms186ms189ms
ngrok10/10658ms (+283%)695ms (+274%)985ms (+421%)
cloudflared quick tunnel2/108.89s (+5,069%)9.00s (+4,739%)9.00s (+4,662%)

One shard is the default because it spins up fastest. More shards trade extra cold starts for more total throughput: 100 concurrent 2MiB streams through one shard took 26.34s p50, while 150 concurrent 2MiB streams spread over 256 warmed shards took 9.76s p50.

Captun startup chart

The scripts used for these numbers are scripts/benchmark-startup.ts and scripts/benchmark-streams.ts; the compact recorded results are in docs/performance, with notes in docs/benchmarks.md.

For test and development traffic, this should usually cost effectively nothing on Cloudflare: the Workers Free plan includes daily Worker requests, and Durable Objects have their own included free usage. Check pricing before serious volume, because connected Durable Objects cannot hibernate while the WebSocket is open.

How Does It Work?

We just pass fetch() through fetch(). No, really.

With Cap'n Web, the Node client opens a WebSocket RPC session to the Worker and exposes its local fetcher as the session's main capability. The Worker's tunnel handle is a stub for that capability, whose only interesting method is fetch(request). From then on, the Worker can forward public HTTP requests to that function and return the resulting Response.

All you need is fetch(). Requests, responses, headers, bodies, streams, SSE, and uploads are already web standards; this is the web-standards way this should work.

sequenceDiagram
participant HTTP as HTTP client
participant Gateway as Tunnel Gateway / CaptunServerShard
participant Client as Node client
Client->>Gateway: WebSocket RPC connect to ?captun-connect=1&captun-name=demo with fetcher as main capability
Gateway-->>Client: ready({ url })
HTTP->>Gateway: GET /demo/report
Gateway->>Client: fetch(request)
Client-->>Gateway: Response
Gateway-->>HTTP: Response
Loading

See examples/weather-reporter for a small workspace package that imports captun and has its own e2e tests.

Development

The Worker needs the CaptunServerShard Durable Object binding and migration from wrangler.jsonc. For local development:

pnpm install
pnpm run build
pnpm run dev

Run tests with pnpm test. The root e2e suite uses Miniflare by default; set CAPTUN_GATEWAY, with optional CAPTUN_TOKEN, to run the same cases against a deployed Worker.

End-to-end smoke tests for build, dry-run deploy, local wrangler dev, tunnel, and curl live in scripts/smoke/ with documentation in docs/smoke-test.md:

pnpm smoke
./scripts/smoke-test.sh list
./scripts/smoke-test.sh step-5-tunnel-local

Caveats

Captun is intentionally small. It is a reference implementation you can copy into a Worker or Durable Object, not a managed tunnel product.

It is fast but less durable than Cloudflare Tunnel. There is no redundant connection in another data center, and a connected Durable Object can still be restarted, so an in-flight request can fail.

Large binary streams are slower than small requests because a Response body crosses the Cap'n Web WebSocket/RPC session rather than getting spliced as a native HTTP socket. For webhook callbacks, mocked internet egress, local previews, and e2e tests, that tradeoff is usually fine.

Connecting a second client with the same tunnel name replaces the previous connection. Malformed percent-encoding in a folder tunnel name is rejected as a missing tunnel name.

About

Minimal Cap'n Web tunnel for Cloudflare Workers and Node

Resources

Stars

23 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Captun (cap[nweb] tun[nel])

Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a Node process over Cap'n Web.

Quick start

Expose a local HTTP server with the hosted captun.sh tunnel service:

npx captun 3000

That prints a public URL like https://abc123.captun.sh and forwards requests to localhost:3000.

If you want your own tunnel server, deploy a captun Worker to your Cloudflare account. You can think of this like your own personal ngrok server, but faster:

deploy expects Cloudflare auth to already be available. Run npx wrangler login once, or set CLOUDFLARE_API_TOKEN for CI and other non-interactive shells.

npx captun deploy

The deploy command uses wrangler under the hood to deploy an opinionated captun Tunnel Gateway to your Cloudflare account, then stores its gateway URL and token in an XDG config file for later tunnel commands.

Programmatic usage

You can use the hosted service from code for receiving HTTP requests. First npm install captun to add it as a dependency. Then create it:

import{createCaptunTunnel}from"captun";consttunnel=awaitcreateCaptunTunnel({fetch: async(request)=>{consturl=newURL(request.url);if(url.pathname.endsWith("/webhook")){console.log("Received a webhook:",awaitrequest.json());returnResponse.json({ok: true});}returnnewResponse("not found",{status: 404});},});console.log(`Listening to webhooks on ${tunnel.url}/webhook`);awaitnewPromise(()=>{});// stay alive until killed

That's all you need! No local ports, just a fetch function.

WebSockets

Tunnels forward WebSockets too: npx captun 3000 exposes any local WebSocket server (socket.io, ws, Bun, Deno, ...) with handshake headers, subprotocols, binary messages, and close codes passing through. In code, a fetch handler accepts WebSockets Workers-style on any runtime:

import{createCaptunTunnel,createWebSocketResponse,isWebSocketUpgradeRequest,WebSocketPair,}from"captun";awaitcreateCaptunTunnel({fetch(request){if(!isWebSocketUpgradeRequest(request))returnnewResponse("hello");constpair=newWebSocketPair();pair[1].accept();pair[1].addEventListener("message",(event)=>pair[1].send(`echo:${event.data}`));returncreateWebSocketResponse(pair[0]);},});

Connections are relayed message by message over the tunnel, so ping/pong and compression are per-hop, close codes outside 1000/3000–4999 degrade to a plain close, and messages are capped at 16MiB so one oversized frame can't take down the tunnel.

Vite plugin

captun/vite serves your Vite dev server (and vite preview) through a public tunnel URL — handy for receiving webhooks against local code, sharing work in progress, or pointing remote devices and agents at your dev server.

// vite.config.tsimport{defineConfig}from"vite";importcaptunfrom"captun/vite";exportdefaultdefineConfig({plugins: [captun()],});

vite dev then prints a public URL next to the local ones:

 ➜ Local: http://localhost:5173/
➜ Captun: https://abc123.captun.sh

The plugin is a thin wrapper around createCaptunTunnel: it waits for the server to start listening, opens a tunnel, and forwards every public request to the local server. All client tunnel options pass straight through, plus two plugin-level callbacks:

captun({// createCaptunTunnel optionsgateway: process.env.CAPTUN_GATEWAY,// Tunnel Gateway URL; defaults to the hosted captun.sh servicename: "my-app",// Tunnel Name used in the public URL; random when omittedtoken: process.env.CAPTUN_TOKEN,// Connect Token; random when omitted// plugin optionsonTunnel: ({ url, token })=>{// runs once the tunnel is connected, e.g. to register a webhook URL;// replaces the default "➜ Captun: <url>" log},onError: (error)=>{// runs when creating the tunnel fails; replaces the default error log// (which leaves the server running). Rethrow to make the failure fatal.},});

After npx captun deploy, point the plugin at your own self-hosted gateway by passing your deployment's gateway and token (for example via environment variables, as above). To only tunnel on demand, make the plugin conditional in your config:

plugins: [process.env.TUNNEL ? captun() : undefined],

Caveats: WebSockets are not forwarded, so Vite HMR only works on the local URL — the tunnel is for plain HTTP (webhooks, previews, e2e tests). For an https dev server, Node must trust the server's certificate (self-signed dev certificates will fail the local hop).

Advanced usage

The captun worker.ts implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in iterate/iterate). For example, here's a weather application which allows mocking its egress to the weather API:

import{DurableObject}from"cloudflare:workers";import{acceptFetcherCapability,typeFetcherStub}from"captun";typeWeatherReporterEnv=Env&{WEATHER_REPORTER_EGRESS: DurableObjectNamespace<WeatherReporterEgressTunnel>;};exportclassWeatherReporterEgressTunnelextendsDurableObject<WeatherReporterEnv>{privateegressFetcher: FetcherStub|undefined;asyncfetch(request: Request){consturl=newURL(request.url);if(url.pathname==="/weather"){// Here's the value our app provides: fetching and gorgeously formatting weather dataconstcity=url.searchParams.get("city");constresponse=awaitthis.egressFetch(`https://wttr.in/${city}?format=j1`);constweather=awaitresponse.json<{current_condition: [{temp_C: string}]}>();returnnewResponse(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`,);}if(url.pathname==="/__intercept-egress-traffic"){// Here we set up our worker to allow clients/tests to intercept egress trafficthis.egressFetcher?.[Symbol.dispose]();const{ response, fetcher }=acceptFetcherCapability({onDisconnect: ()=>{if(this.egressFetcher===fetcher)this.egressFetcher=undefined;},});this.egressFetcher=fetcher;queueMicrotask(()=>voidfetcher.ready({url: newURL(request.url).origin}));returnresponse;}returnnewResponse("Not found\n",{status: 404});}getegressFetch(): typeoffetch{if(this.egressFetcher){returnasync(input,init)=>this.egressFetcher!.fetch(newRequest(input,init));}returnfetch;}}exportdefault{fetch(request: Request,env: WeatherReporterEnv){returnenv.WEATHER_REPORTER_EGRESS.getByName("default").fetch(request);},}satisfiesExportedHandler<WeatherReporterEnv>;

The core client/server pieces (createCaptunTunnel, acceptFetcherCapability, acceptFetcherCapabilityFromSocket, Fetcher, and FetcherStub) live in src/index.ts — small TypeScript wrappers around Cap'n Web. For a self-hosted Cloudflare Tunnel Gateway, copy or adapt src/server/worker.ts and the Durable Object binding in wrangler.jsonc. The Iterate-operated hosted service is separate: its product surface lives under src/hosted, with wrangler.hosted.jsonc as its deployment config.

Runtime Adapters for accepting Fetcher Capabilities outside Cloudflare Workers are implemented under src/server and exported as captun/node, captun/bun, and captun/deno. See examples/node, examples/bun, and examples/deno for the same small weather egress test running in each runtime.

Advanced CLI Usage

The CLI is mostly focused on ngrok-style use-cases. Without local config it uses the hosted captun.sh service. Once you have run npx captun deploy, further commands will pick up your self-hosted gateway URL and token from your machine's captun config. You can also pass them explicitly (for example, to create a tunnel using a deployment created from someone else's machine):

npx captun 3000 --gateway 'https://captun.youraccount.workers.dev' --token abc123

By default, the npx captun 3000 command will generate a name for the tunnel it creates. You can customise this with --name:

npx captun 3000 --name my-very-serious-tunnel-name

By default the worker routes /my-tunnel/foo/bar to the capnweb session for "my-tunnel", and becomes a corresponding HTTP request with pathname /foo/bar when it reaches your client.

Custom domains

Running npx captun deploy interactively walks you through where the tunnel URLs should live. There are four options, and which one is best for you depends on the kind of apps you want to tunnel to and whether you already have a domain on Cloudflare.

Routing is controlled by a single Worker env var, CUSTOM_HOSTNAME. When unset (workers.dev deploys), tunnels use folder routing: the first path segment is the tunnel name. When set (custom-domain deploys), tunnels use subdomain routing — the last DNS label before CUSTOM_HOSTNAME is the tunnel name, and anything to the left of it is ignored. The deploy wizard sets CUSTOM_HOSTNAME for you; the parsing logic lives in getTunnelNameFromUrl in src/server/tunnel-addressing.ts.

1. <tunnel>.<account>.workers.dev/<tunnel-name> (default)

Free, instant, no DNS setup. The tunnel URLs look like https://captun.<account>.workers.dev/demo and your app runs under the /demo path prefix.

Pick this if: you want the fastest possible setup, and the apps you're tunneling to are happy under a path prefix.

Caveat: apps that assume they live at / will misbehave — absolute redirects to /login, OAuth callbacks hardcoded to a root URL, cookies scoped to Path=/, and similar. If you hit any of those, pick one of the options below.

2. <tunnel>.your-domain.com (free wildcard on an existing zone)

Free, instant. Tunnel URLs become https://demo.your-domain.com/ — apps see a naked hostname, so path-prefix issues from option 1 disappear. Universal SSL covers first-level subdomains so no cert work is needed.

npx captun deploy --route '*.your-domain.com/*' --zone your-domain.com

Pick this if: you have a Cloudflare-managed domain you can dedicate to tunnels.

Caveat: the worker route *.your-domain.com/* will catch every otherwise-unrouted subdomain on this zone, which means you should only use this on a domain you've actually set aside for tunnels. Don't point it at your main production domain.

3. <tunnel>.captun.your-domain.com (requires Advanced Certificate Manager)

Tunnels are namespaced under captun. on your existing domain (or whatever subdomain prefix you pick in the wizard), so the rest of the zone is unaffected.

npx captun deploy --route '*.captun.your-domain.com/*' --zone your-domain.com

Universal SSL only covers the apex and first-level subdomains, so *.captun.your-domain.com (a second-level wildcard) needs a separately-ordered certificate. The wizard handles this by ordering an Advanced Certificate Manager certificate pack for *.captun.your-domain.com + captun.your-domain.com and waiting for it to become active.

Pick this if: you want clean naming on an existing domain without the foot-gun of option 2.

Caveat: ACM is $10/month per zone. The wizard checks whether ACM is already enabled and bails with a link to the dashboard if it isn't — there's no way to subscribe to ACM via API.

4. Dedicated tunnel domain

If you don't have a suitable Cloudflare-managed domain, registering a throwaway one (e.g. my-tunnels.com) and using it with option 2 ends up cheaper than enabling ACM for option 3 (~$9/year versus $10/month).

  1. Register a domain via Cloudflare Registrar or any third-party registrar.
  2. Add the domain to your Cloudflare account and wait for the zone to become active.
  3. Re-run captun deploy and pick option 2 for the new zone.

Sharding

By default, all tunnel names live in one warm CaptunServerShard Durable Object. That minimizes cold-start latency. Use --shards only when you need more aggregate throughput for many concurrent large responses:

npx captun deploy --shards 256

All of captun's public API (both the client createCaptunTunnel and the server-side acceptFetcherCapability) is exported from the single captun entry point. acceptFetcherCapabilityFromSocket(socket) is also exported for Workers that have already performed the WebSocket upgrade themselves.

Performance

On May 18, 2026 from London, one warm-shard Captun tunnel reached first fetch in 188ms p50. Rechecking provider startup on the same day showed ngrok was much faster than the earlier sample: one ngrok ad-hoc tunnel reached 451ms, and 10 concurrent ngrok tunnels reached 658ms p50. Cloudflared quick tunnels still took about 8.5-9s when successful because the trycloudflare.com hostname was printed several seconds before DNS/public routing was ready.

Ad-hoc tunnelFirst fetch
captun188ms
ngrok451ms (+140%)
cloudflared quick tunnel8.51s (+4,427%)
10 concurrent ad-hoc tunnelsSuccessfulp50p90p99
captun10/10172ms186ms189ms
ngrok10/10658ms (+283%)695ms (+274%)985ms (+421%)
cloudflared quick tunnel2/108.89s (+5,069%)9.00s (+4,739%)9.00s (+4,662%)

One shard is the default because it spins up fastest. More shards trade extra cold starts for more total throughput: 100 concurrent 2MiB streams through one shard took 26.34s p50, while 150 concurrent 2MiB streams spread over 256 warmed shards took 9.76s p50.

Captun startup chart

The scripts used for these numbers are scripts/benchmark-startup.ts and scripts/benchmark-streams.ts; the compact recorded results are in docs/performance, with notes in docs/benchmarks.md.

For test and development traffic, this should usually cost effectively nothing on Cloudflare: the Workers Free plan includes daily Worker requests, and Durable Objects have their own included free usage. Check pricing before serious volume, because connected Durable Objects cannot hibernate while the WebSocket is open.

How Does It Work?

We just pass fetch() through fetch(). No, really.

With Cap'n Web, the Node client opens a WebSocket RPC session to the Worker and exposes its local fetcher as the session's main capability. The Worker's tunnel handle is a stub for that capability, whose only interesting method is fetch(request). From then on, the Worker can forward public HTTP requests to that function and return the resulting Response.

All you need is fetch(). Requests, responses, headers, bodies, streams, SSE, and uploads are already web standards; this is the web-standards way this should work.

sequenceDiagram
participant HTTP as HTTP client
participant Gateway as Tunnel Gateway / CaptunServerShard
participant Client as Node client
Client->>Gateway: WebSocket RPC connect to ?captun-connect=1&captun-name=demo with fetcher as main capability
Gateway-->>Client: ready({ url })
HTTP->>Gateway: GET /demo/report
Gateway->>Client: fetch(request)
Client-->>Gateway: Response
Gateway-->>HTTP: Response
Loading

See examples/weather-reporter for a small workspace package that imports captun and has its own e2e tests.

Development

The Worker needs the CaptunServerShard Durable Object binding and migration from wrangler.jsonc. For local development:

pnpm install
pnpm run build
pnpm run dev

Run tests with pnpm test. The root e2e suite uses Miniflare by default; set CAPTUN_GATEWAY, with optional CAPTUN_TOKEN, to run the same cases against a deployed Worker.

End-to-end smoke tests for build, dry-run deploy, local wrangler dev, tunnel, and curl live in scripts/smoke/ with documentation in docs/smoke-test.md:

pnpm smoke
./scripts/smoke-test.sh list
./scripts/smoke-test.sh step-5-tunnel-local

Caveats

Captun is intentionally small. It is a reference implementation you can copy into a Worker or Durable Object, not a managed tunnel product.

It is fast but less durable than Cloudflare Tunnel. There is no redundant connection in another data center, and a connected Durable Object can still be restarted, so an in-flight request can fail.

Large binary streams are slower than small requests because a Response body crosses the Cap'n Web WebSocket/RPC session rather than getting spliced as a native HTTP socket. For webhook callbacks, mocked internet egress, local previews, and e2e tests, that tradeoff is usually fine.

Connecting a second client with the same tunnel name replaces the previous connection. Malformed percent-encoding in a folder tunnel name is rejected as a missing tunnel name.

About

Minimal Cap'n Web tunnel for Cloudflare Workers and Node

Resources

Stars

23 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Captun (cap[nweb] tun[nel])

Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a Node process over Cap'n Web.

Quick start

Expose a local HTTP server with the hosted captun.sh tunnel service:

npx captun 3000

That prints a public URL like https://abc123.captun.sh and forwards requests to localhost:3000.

If you want your own tunnel server, deploy a captun Worker to your Cloudflare account. You can think of this like your own personal ngrok server, but faster:

deploy expects Cloudflare auth to already be available. Run npx wrangler login once, or set CLOUDFLARE_API_TOKEN for CI and other non-interactive shells.

npx captun deploy

The deploy command uses wrangler under the hood to deploy an opinionated captun Tunnel Gateway to your Cloudflare account, then stores its gateway URL and token in an XDG config file for later tunnel commands.

Programmatic usage

You can use the hosted service from code for receiving HTTP requests. First npm install captun to add it as a dependency. Then create it:

import{createCaptunTunnel}from"captun";consttunnel=awaitcreateCaptunTunnel({fetch: async(request)=>{consturl=newURL(request.url);if(url.pathname.endsWith("/webhook")){console.log("Received a webhook:",awaitrequest.json());returnResponse.json({ok: true});}returnnewResponse("not found",{status: 404});},});console.log(`Listening to webhooks on ${tunnel.url}/webhook`);awaitnewPromise(()=>{});// stay alive until killed

That's all you need! No local ports, just a fetch function.

WebSockets

Tunnels forward WebSockets too: npx captun 3000 exposes any local WebSocket server (socket.io, ws, Bun, Deno, ...) with handshake headers, subprotocols, binary messages, and close codes passing through. In code, a fetch handler accepts WebSockets Workers-style on any runtime:

import{createCaptunTunnel,createWebSocketResponse,isWebSocketUpgradeRequest,WebSocketPair,}from"captun";awaitcreateCaptunTunnel({fetch(request){if(!isWebSocketUpgradeRequest(request))returnnewResponse("hello");constpair=newWebSocketPair();pair[1].accept();pair[1].addEventListener("message",(event)=>pair[1].send(`echo:${event.data}`));returncreateWebSocketResponse(pair[0]);},});

Connections are relayed message by message over the tunnel, so ping/pong and compression are per-hop, close codes outside 1000/3000–4999 degrade to a plain close, and messages are capped at 16MiB so one oversized frame can't take down the tunnel.

Vite plugin

captun/vite serves your Vite dev server (and vite preview) through a public tunnel URL — handy for receiving webhooks against local code, sharing work in progress, or pointing remote devices and agents at your dev server.

// vite.config.tsimport{defineConfig}from"vite";importcaptunfrom"captun/vite";exportdefaultdefineConfig({plugins: [captun()],});

vite dev then prints a public URL next to the local ones:

 ➜ Local: http://localhost:5173/
➜ Captun: https://abc123.captun.sh

The plugin is a thin wrapper around createCaptunTunnel: it waits for the server to start listening, opens a tunnel, and forwards every public request to the local server. All client tunnel options pass straight through, plus two plugin-level callbacks:

captun({// createCaptunTunnel optionsgateway: process.env.CAPTUN_GATEWAY,// Tunnel Gateway URL; defaults to the hosted captun.sh servicename: "my-app",// Tunnel Name used in the public URL; random when omittedtoken: process.env.CAPTUN_TOKEN,// Connect Token; random when omitted// plugin optionsonTunnel: ({ url, token })=>{// runs once the tunnel is connected, e.g. to register a webhook URL;// replaces the default "➜ Captun: <url>" log},onError: (error)=>{// runs when creating the tunnel fails; replaces the default error log// (which leaves the server running). Rethrow to make the failure fatal.},});

After npx captun deploy, point the plugin at your own self-hosted gateway by passing your deployment's gateway and token (for example via environment variables, as above). To only tunnel on demand, make the plugin conditional in your config:

plugins: [process.env.TUNNEL ? captun() : undefined],

Caveats: WebSockets are not forwarded, so Vite HMR only works on the local URL — the tunnel is for plain HTTP (webhooks, previews, e2e tests). For an https dev server, Node must trust the server's certificate (self-signed dev certificates will fail the local hop).

Advanced usage

The captun worker.ts implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in iterate/iterate). For example, here's a weather application which allows mocking its egress to the weather API:

import{DurableObject}from"cloudflare:workers";import{acceptFetcherCapability,typeFetcherStub}from"captun";typeWeatherReporterEnv=Env&{WEATHER_REPORTER_EGRESS: DurableObjectNamespace<WeatherReporterEgressTunnel>;};exportclassWeatherReporterEgressTunnelextendsDurableObject<WeatherReporterEnv>{privateegressFetcher: FetcherStub|undefined;asyncfetch(request: Request){consturl=newURL(request.url);if(url.pathname==="/weather"){// Here's the value our app provides: fetching and gorgeously formatting weather dataconstcity=url.searchParams.get("city");constresponse=awaitthis.egressFetch(`https://wttr.in/${city}?format=j1`);constweather=awaitresponse.json<{current_condition: [{temp_C: string}]}>();returnnewResponse(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`,);}if(url.pathname==="/__intercept-egress-traffic"){// Here we set up our worker to allow clients/tests to intercept egress trafficthis.egressFetcher?.[Symbol.dispose]();const{ response, fetcher }=acceptFetcherCapability({onDisconnect: ()=>{if(this.egressFetcher===fetcher)this.egressFetcher=undefined;},});this.egressFetcher=fetcher;queueMicrotask(()=>voidfetcher.ready({url: newURL(request.url).origin}));returnresponse;}returnnewResponse("Not found\n",{status: 404});}getegressFetch(): typeoffetch{if(this.egressFetcher){returnasync(input,init)=>this.egressFetcher!.fetch(newRequest(input,init));}returnfetch;}}exportdefault{fetch(request: Request,env: WeatherReporterEnv){returnenv.WEATHER_REPORTER_EGRESS.getByName("default").fetch(request);},}satisfiesExportedHandler<WeatherReporterEnv>;

The core client/server pieces (createCaptunTunnel, acceptFetcherCapability, acceptFetcherCapabilityFromSocket, Fetcher, and FetcherStub) live in src/index.ts — small TypeScript wrappers around Cap'n Web. For a self-hosted Cloudflare Tunnel Gateway, copy or adapt src/server/worker.ts and the Durable Object binding in wrangler.jsonc. The Iterate-operated hosted service is separate: its product surface lives under src/hosted, with wrangler.hosted.jsonc as its deployment config.

Runtime Adapters for accepting Fetcher Capabilities outside Cloudflare Workers are implemented under src/server and exported as captun/node, captun/bun, and captun/deno. See examples/node, examples/bun, and examples/deno for the same small weather egress test running in each runtime.

Advanced CLI Usage

The CLI is mostly focused on ngrok-style use-cases. Without local config it uses the hosted captun.sh service. Once you have run npx captun deploy, further commands will pick up your self-hosted gateway URL and token from your machine's captun config. You can also pass them explicitly (for example, to create a tunnel using a deployment created from someone else's machine):

npx captun 3000 --gateway 'https://captun.youraccount.workers.dev' --token abc123

By default, the npx captun 3000 command will generate a name for the tunnel it creates. You can customise this with --name:

npx captun 3000 --name my-very-serious-tunnel-name

By default the worker routes /my-tunnel/foo/bar to the capnweb session for "my-tunnel", and becomes a corresponding HTTP request with pathname /foo/bar when it reaches your client.

Custom domains

Running npx captun deploy interactively walks you through where the tunnel URLs should live. There are four options, and which one is best for you depends on the kind of apps you want to tunnel to and whether you already have a domain on Cloudflare.

Routing is controlled by a single Worker env var, CUSTOM_HOSTNAME. When unset (workers.dev deploys), tunnels use folder routing: the first path segment is the tunnel name. When set (custom-domain deploys), tunnels use subdomain routing — the last DNS label before CUSTOM_HOSTNAME is the tunnel name, and anything to the left of it is ignored. The deploy wizard sets CUSTOM_HOSTNAME for you; the parsing logic lives in getTunnelNameFromUrl in src/server/tunnel-addressing.ts.

1. <tunnel>.<account>.workers.dev/<tunnel-name> (default)

Free, instant, no DNS setup. The tunnel URLs look like https://captun.<account>.workers.dev/demo and your app runs under the /demo path prefix.

Pick this if: you want the fastest possible setup, and the apps you're tunneling to are happy under a path prefix.

Caveat: apps that assume they live at / will misbehave — absolute redirects to /login, OAuth callbacks hardcoded to a root URL, cookies scoped to Path=/, and similar. If you hit any of those, pick one of the options below.

2. <tunnel>.your-domain.com (free wildcard on an existing zone)

Free, instant. Tunnel URLs become https://demo.your-domain.com/ — apps see a naked hostname, so path-prefix issues from option 1 disappear. Universal SSL covers first-level subdomains so no cert work is needed.

npx captun deploy --route '*.your-domain.com/*' --zone your-domain.com

Pick this if: you have a Cloudflare-managed domain you can dedicate to tunnels.

Caveat: the worker route *.your-domain.com/* will catch every otherwise-unrouted subdomain on this zone, which means you should only use this on a domain you've actually set aside for tunnels. Don't point it at your main production domain.

3. <tunnel>.captun.your-domain.com (requires Advanced Certificate Manager)

Tunnels are namespaced under captun. on your existing domain (or whatever subdomain prefix you pick in the wizard), so the rest of the zone is unaffected.

npx captun deploy --route '*.captun.your-domain.com/*' --zone your-domain.com

Universal SSL only covers the apex and first-level subdomains, so *.captun.your-domain.com (a second-level wildcard) needs a separately-ordered certificate. The wizard handles this by ordering an Advanced Certificate Manager certificate pack for *.captun.your-domain.com + captun.your-domain.com and waiting for it to become active.

Pick this if: you want clean naming on an existing domain without the foot-gun of option 2.

Caveat: ACM is $10/month per zone. The wizard checks whether ACM is already enabled and bails with a link to the dashboard if it isn't — there's no way to subscribe to ACM via API.

4. Dedicated tunnel domain

If you don't have a suitable Cloudflare-managed domain, registering a throwaway one (e.g. my-tunnels.com) and using it with option 2 ends up cheaper than enabling ACM for option 3 (~$9/year versus $10/month).

  1. Register a domain via Cloudflare Registrar or any third-party registrar.
  2. Add the domain to your Cloudflare account and wait for the zone to become active.
  3. Re-run captun deploy and pick option 2 for the new zone.

Sharding

By default, all tunnel names live in one warm CaptunServerShard Durable Object. That minimizes cold-start latency. Use --shards only when you need more aggregate throughput for many concurrent large responses:

npx captun deploy --shards 256

All of captun's public API (both the client createCaptunTunnel and the server-side acceptFetcherCapability) is exported from the single captun entry point. acceptFetcherCapabilityFromSocket(socket) is also exported for Workers that have already performed the WebSocket upgrade themselves.

Performance

On May 18, 2026 from London, one warm-shard Captun tunnel reached first fetch in 188ms p50. Rechecking provider startup on the same day showed ngrok was much faster than the earlier sample: one ngrok ad-hoc tunnel reached 451ms, and 10 concurrent ngrok tunnels reached 658ms p50. Cloudflared quick tunnels still took about 8.5-9s when successful because the trycloudflare.com hostname was printed several seconds before DNS/public routing was ready.

Ad-hoc tunnelFirst fetch
captun188ms
ngrok451ms (+140%)
cloudflared quick tunnel8.51s (+4,427%)
10 concurrent ad-hoc tunnelsSuccessfulp50p90p99
captun10/10172ms186ms189ms
ngrok10/10658ms (+283%)695ms (+274%)985ms (+421%)
cloudflared quick tunnel2/108.89s (+5,069%)9.00s (+4,739%)9.00s (+4,662%)

One shard is the default because it spins up fastest. More shards trade extra cold starts for more total throughput: 100 concurrent 2MiB streams through one shard took 26.34s p50, while 150 concurrent 2MiB streams spread over 256 warmed shards took 9.76s p50.

Captun startup chart

The scripts used for these numbers are scripts/benchmark-startup.ts and scripts/benchmark-streams.ts; the compact recorded results are in docs/performance, with notes in docs/benchmarks.md.

For test and development traffic, this should usually cost effectively nothing on Cloudflare: the Workers Free plan includes daily Worker requests, and Durable Objects have their own included free usage. Check pricing before serious volume, because connected Durable Objects cannot hibernate while the WebSocket is open.

How Does It Work?

We just pass fetch() through fetch(). No, really.

With Cap'n Web, the Node client opens a WebSocket RPC session to the Worker and exposes its local fetcher as the session's main capability. The Worker's tunnel handle is a stub for that capability, whose only interesting method is fetch(request). From then on, the Worker can forward public HTTP requests to that function and return the resulting Response.

All you need is fetch(). Requests, responses, headers, bodies, streams, SSE, and uploads are already web standards; this is the web-standards way this should work.

sequenceDiagram
participant HTTP as HTTP client
participant Gateway as Tunnel Gateway / CaptunServerShard
participant Client as Node client
Client->>Gateway: WebSocket RPC connect to ?captun-connect=1&captun-name=demo with fetcher as main capability
Gateway-->>Client: ready({ url })
HTTP->>Gateway: GET /demo/report
Gateway->>Client: fetch(request)
Client-->>Gateway: Response
Gateway-->>HTTP: Response
Loading

See examples/weather-reporter for a small workspace package that imports captun and has its own e2e tests.

Development

The Worker needs the CaptunServerShard Durable Object binding and migration from wrangler.jsonc. For local development:

pnpm install
pnpm run build
pnpm run dev

Run tests with pnpm test. The root e2e suite uses Miniflare by default; set CAPTUN_GATEWAY, with optional CAPTUN_TOKEN, to run the same cases against a deployed Worker.

End-to-end smoke tests for build, dry-run deploy, local wrangler dev, tunnel, and curl live in scripts/smoke/ with documentation in docs/smoke-test.md:

pnpm smoke
./scripts/smoke-test.sh list
./scripts/smoke-test.sh step-5-tunnel-local

Caveats

Captun is intentionally small. It is a reference implementation you can copy into a Worker or Durable Object, not a managed tunnel product.

It is fast but less durable than Cloudflare Tunnel. There is no redundant connection in another data center, and a connected Durable Object can still be restarted, so an in-flight request can fail.

Large binary streams are slower than small requests because a Response body crosses the Cap'n Web WebSocket/RPC session rather than getting spliced as a native HTTP socket. For webhook callbacks, mocked internet egress, local previews, and e2e tests, that tradeoff is usually fine.

Connecting a second client with the same tunnel name replaces the previous connection. Malformed percent-encoding in a folder tunnel name is rejected as a missing tunnel name.

About

Minimal Cap'n Web tunnel for Cloudflare Workers and Node

Resources

Stars

23 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Captun (cap[nweb] tun[nel])

Captun is a tiny reference implementation of a self-hosted ngrok or Cloudflare Tunnel alternative. It runs the public side on Cloudflare Workers and sends matching HTTP requests back to a Node process over Cap'n Web.

Quick start

Expose a local HTTP server with the hosted captun.sh tunnel service:

npx captun 3000

That prints a public URL like https://abc123.captun.sh and forwards requests to localhost:3000.

If you want your own tunnel server, deploy a captun Worker to your Cloudflare account. You can think of this like your own personal ngrok server, but faster:

deploy expects Cloudflare auth to already be available. Run npx wrangler login once, or set CLOUDFLARE_API_TOKEN for CI and other non-interactive shells.

npx captun deploy

The deploy command uses wrangler under the hood to deploy an opinionated captun Tunnel Gateway to your Cloudflare account, then stores its gateway URL and token in an XDG config file for later tunnel commands.

Programmatic usage

You can use the hosted service from code for receiving HTTP requests. First npm install captun to add it as a dependency. Then create it:

import{createCaptunTunnel}from"captun";consttunnel=awaitcreateCaptunTunnel({fetch: async(request)=>{consturl=newURL(request.url);if(url.pathname.endsWith("/webhook")){console.log("Received a webhook:",awaitrequest.json());returnResponse.json({ok: true});}returnnewResponse("not found",{status: 404});},});console.log(`Listening to webhooks on ${tunnel.url}/webhook`);awaitnewPromise(()=>{});// stay alive until killed

That's all you need! No local ports, just a fetch function.

WebSockets

Tunnels forward WebSockets too: npx captun 3000 exposes any local WebSocket server (socket.io, ws, Bun, Deno, ...) with handshake headers, subprotocols, binary messages, and close codes passing through. In code, a fetch handler accepts WebSockets Workers-style on any runtime:

import{createCaptunTunnel,createWebSocketResponse,isWebSocketUpgradeRequest,WebSocketPair,}from"captun";awaitcreateCaptunTunnel({fetch(request){if(!isWebSocketUpgradeRequest(request))returnnewResponse("hello");constpair=newWebSocketPair();pair[1].accept();pair[1].addEventListener("message",(event)=>pair[1].send(`echo:${event.data}`));returncreateWebSocketResponse(pair[0]);},});

Connections are relayed message by message over the tunnel, so ping/pong and compression are per-hop, close codes outside 1000/3000–4999 degrade to a plain close, and messages are capped at 16MiB so one oversized frame can't take down the tunnel.

Vite plugin

captun/vite serves your Vite dev server (and vite preview) through a public tunnel URL — handy for receiving webhooks against local code, sharing work in progress, or pointing remote devices and agents at your dev server.

// vite.config.tsimport{defineConfig}from"vite";importcaptunfrom"captun/vite";exportdefaultdefineConfig({plugins: [captun()],});

vite dev then prints a public URL next to the local ones:

 ➜ Local: http://localhost:5173/
➜ Captun: https://abc123.captun.sh

The plugin is a thin wrapper around createCaptunTunnel: it waits for the server to start listening, opens a tunnel, and forwards every public request to the local server. All client tunnel options pass straight through, plus two plugin-level callbacks:

captun({// createCaptunTunnel optionsgateway: process.env.CAPTUN_GATEWAY,// Tunnel Gateway URL; defaults to the hosted captun.sh servicename: "my-app",// Tunnel Name used in the public URL; random when omittedtoken: process.env.CAPTUN_TOKEN,// Connect Token; random when omitted// plugin optionsonTunnel: ({ url, token })=>{// runs once the tunnel is connected, e.g. to register a webhook URL;// replaces the default "➜ Captun: <url>" log},onError: (error)=>{// runs when creating the tunnel fails; replaces the default error log// (which leaves the server running). Rethrow to make the failure fatal.},});

After npx captun deploy, point the plugin at your own self-hosted gateway by passing your deployment's gateway and token (for example via environment variables, as above). To only tunnel on demand, make the plugin conditional in your config:

plugins: [process.env.TUNNEL ? captun() : undefined],

Caveats: WebSockets are not forwarded, so Vite HMR only works on the local URL — the tunnel is for plain HTTP (webhooks, previews, e2e tests). For an https dev server, Node must trust the server's certificate (self-signed dev certificates will fail the local hop).

Advanced usage

The captun worker.ts implementation has useful opinions about "named tunnels", but you can also take full control of the server implementation (which is what we do in iterate/iterate). For example, here's a weather application which allows mocking its egress to the weather API:

import{DurableObject}from"cloudflare:workers";import{acceptFetcherCapability,typeFetcherStub}from"captun";typeWeatherReporterEnv=Env&{WEATHER_REPORTER_EGRESS: DurableObjectNamespace<WeatherReporterEgressTunnel>;};exportclassWeatherReporterEgressTunnelextendsDurableObject<WeatherReporterEnv>{privateegressFetcher: FetcherStub|undefined;asyncfetch(request: Request){consturl=newURL(request.url);if(url.pathname==="/weather"){// Here's the value our app provides: fetching and gorgeously formatting weather dataconstcity=url.searchParams.get("city");constresponse=awaitthis.egressFetch(`https://wttr.in/${city}?format=j1`);constweather=awaitresponse.json<{current_condition: [{temp_C: string}]}>();returnnewResponse(`The temperature in ${city} is ${weather.current_condition[0].temp_C} celsius`,);}if(url.pathname==="/__intercept-egress-traffic"){// Here we set up our worker to allow clients/tests to intercept egress trafficthis.egressFetcher?.[Symbol.dispose]();const{ response, fetcher }=acceptFetcherCapability({onDisconnect: ()=>{if(this.egressFetcher===fetcher)this.egressFetcher=undefined;},});this.egressFetcher=fetcher;queueMicrotask(()=>voidfetcher.ready({url: newURL(request.url).origin}));returnresponse;}returnnewResponse("Not found\n",{status: 404});}getegressFetch(): typeoffetch{if(this.egressFetcher){returnasync(input,init)=>this.egressFetcher!.fetch(newRequest(input,init));}returnfetch;}}exportdefault{fetch(request: Request,env: WeatherReporterEnv){returnenv.WEATHER_REPORTER_EGRESS.getByName("default").fetch(request);},}satisfiesExportedHandler<WeatherReporterEnv>;

The core client/server pieces (createCaptunTunnel, acceptFetcherCapability, acceptFetcherCapabilityFromSocket, Fetcher, and FetcherStub) live in src/index.ts — small TypeScript wrappers around Cap'n Web. For a self-hosted Cloudflare Tunnel Gateway, copy or adapt src/server/worker.ts and the Durable Object binding in wrangler.jsonc. The Iterate-operated hosted service is separate: its product surface lives under src/hosted, with wrangler.hosted.jsonc as its deployment config.

Runtime Adapters for accepting Fetcher Capabilities outside Cloudflare Workers are implemented under src/server and exported as captun/node, captun/bun, and captun/deno. See examples/node, examples/bun, and examples/deno for the same small weather egress test running in each runtime.

Advanced CLI Usage

The CLI is mostly focused on ngrok-style use-cases. Without local config it uses the hosted captun.sh service. Once you have run npx captun deploy, further commands will pick up your self-hosted gateway URL and token from your machine's captun config. You can also pass them explicitly (for example, to create a tunnel using a deployment created from someone else's machine):

npx captun 3000 --gateway 'https://captun.youraccount.workers.dev' --token abc123

By default, the npx captun 3000 command will generate a name for the tunnel it creates. You can customise this with --name:

npx captun 3000 --name my-very-serious-tunnel-name

By default the worker routes /my-tunnel/foo/bar to the capnweb session for "my-tunnel", and becomes a corresponding HTTP request with pathname /foo/bar when it reaches your client.

Custom domains

Running npx captun deploy interactively walks you through where the tunnel URLs should live. There are four options, and which one is best for you depends on the kind of apps you want to tunnel to and whether you already have a domain on Cloudflare.

Routing is controlled by a single Worker env var, CUSTOM_HOSTNAME. When unset (workers.dev deploys), tunnels use folder routing: the first path segment is the tunnel name. When set (custom-domain deploys), tunnels use subdomain routing — the last DNS label before CUSTOM_HOSTNAME is the tunnel name, and anything to the left of it is ignored. The deploy wizard sets CUSTOM_HOSTNAME for you; the parsing logic lives in getTunnelNameFromUrl in src/server/tunnel-addressing.ts.

1. <tunnel>.<account>.workers.dev/<tunnel-name> (default)

Free, instant, no DNS setup. The tunnel URLs look like https://captun.<account>.workers.dev/demo and your app runs under the /demo path prefix.

Pick this if: you want the fastest possible setup, and the apps you're tunneling to are happy under a path prefix.

Caveat: apps that assume they live at / will misbehave — absolute redirects to /login, OAuth callbacks hardcoded to a root URL, cookies scoped to Path=/, and similar. If you hit any of those, pick one of the options below.

2. <tunnel>.your-domain.com (free wildcard on an existing zone)

Free, instant. Tunnel URLs become https://demo.your-domain.com/ — apps see a naked hostname, so path-prefix issues from option 1 disappear. Universal SSL covers first-level subdomains so no cert work is needed.

npx captun deploy --route '*.your-domain.com/*' --zone your-domain.com

Pick this if: you have a Cloudflare-managed domain you can dedicate to tunnels.

Caveat: the worker route *.your-domain.com/* will catch every otherwise-unrouted subdomain on this zone, which means you should only use this on a domain you've actually set aside for tunnels. Don't point it at your main production domain.

3. <tunnel>.captun.your-domain.com (requires Advanced Certificate Manager)

Tunnels are namespaced under captun. on your existing domain (or whatever subdomain prefix you pick in the wizard), so the rest of the zone is unaffected.

npx captun deploy --route '*.captun.your-domain.com/*' --zone your-domain.com

Universal SSL only covers the apex and first-level subdomains, so *.captun.your-domain.com (a second-level wildcard) needs a separately-ordered certificate. The wizard handles this by ordering an Advanced Certificate Manager certificate pack for *.captun.your-domain.com + captun.your-domain.com and waiting for it to become active.

Pick this if: you want clean naming on an existing domain without the foot-gun of option 2.

Caveat: ACM is $10/month per zone. The wizard checks whether ACM is already enabled and bails with a link to the dashboard if it isn't — there's no way to subscribe to ACM via API.

4. Dedicated tunnel domain

If you don't have a suitable Cloudflare-managed domain, registering a throwaway one (e.g. my-tunnels.com) and using it with option 2 ends up cheaper than enabling ACM for option 3 (~$9/year versus $10/month).

  1. Register a domain via Cloudflare Registrar or any third-party registrar.
  2. Add the domain to your Cloudflare account and wait for the zone to become active.
  3. Re-run captun deploy and pick option 2 for the new zone.

Sharding

By default, all tunnel names live in one warm CaptunServerShard Durable Object. That minimizes cold-start latency. Use --shards only when you need more aggregate throughput for many concurrent large responses:

npx captun deploy --shards 256

All of captun's public API (both the client createCaptunTunnel and the server-side acceptFetcherCapability) is exported from the single captun entry point. acceptFetcherCapabilityFromSocket(socket) is also exported for Workers that have already performed the WebSocket upgrade themselves.

Performance

On May 18, 2026 from London, one warm-shard Captun tunnel reached first fetch in 188ms p50. Rechecking provider startup on the same day showed ngrok was much faster than the earlier sample: one ngrok ad-hoc tunnel reached 451ms, and 10 concurrent ngrok tunnels reached 658ms p50. Cloudflared quick tunnels still took about 8.5-9s when successful because the trycloudflare.com hostname was printed several seconds before DNS/public routing was ready.

Ad-hoc tunnelFirst fetch
captun188ms
ngrok451ms (+140%)
cloudflared quick tunnel8.51s (+4,427%)
10 concurrent ad-hoc tunnelsSuccessfulp50p90p99
captun10/10172ms186ms189ms
ngrok10/10658ms (+283%)695ms (+274%)985ms (+421%)
cloudflared quick tunnel2/108.89s (+5,069%)9.00s (+4,739%)9.00s (+4,662%)

One shard is the default because it spins up fastest. More shards trade extra cold starts for more total throughput: 100 concurrent 2MiB streams through one shard took 26.34s p50, while 150 concurrent 2MiB streams spread over 256 warmed shards took 9.76s p50.

Captun startup chart

The scripts used for these numbers are scripts/benchmark-startup.ts and scripts/benchmark-streams.ts; the compact recorded results are in docs/performance, with notes in docs/benchmarks.md.

For test and development traffic, this should usually cost effectively nothing on Cloudflare: the Workers Free plan includes daily Worker requests, and Durable Objects have their own included free usage. Check pricing before serious volume, because connected Durable Objects cannot hibernate while the WebSocket is open.

How Does It Work?

We just pass fetch() through fetch(). No, really.

With Cap'n Web, the Node client opens a WebSocket RPC session to the Worker and exposes its local fetcher as the session's main capability. The Worker's tunnel handle is a stub for that capability, whose only interesting method is fetch(request). From then on, the Worker can forward public HTTP requests to that function and return the resulting Response.

All you need is fetch(). Requests, responses, headers, bodies, streams, SSE, and uploads are already web standards; this is the web-standards way this should work.

sequenceDiagram
participant HTTP as HTTP client
participant Gateway as Tunnel Gateway / CaptunServerShard
participant Client as Node client
Client->>Gateway: WebSocket RPC connect to ?captun-connect=1&captun-name=demo with fetcher as main capability
Gateway-->>Client: ready({ url })
HTTP->>Gateway: GET /demo/report
Gateway->>Client: fetch(request)
Client-->>Gateway: Response
Gateway-->>HTTP: Response
Loading

See examples/weather-reporter for a small workspace package that imports captun and has its own e2e tests.

Development

The Worker needs the CaptunServerShard Durable Object binding and migration from wrangler.jsonc. For local development:

pnpm install
pnpm run build
pnpm run dev

Run tests with pnpm test. The root e2e suite uses Miniflare by default; set CAPTUN_GATEWAY, with optional CAPTUN_TOKEN, to run the same cases against a deployed Worker.

End-to-end smoke tests for build, dry-run deploy, local wrangler dev, tunnel, and curl live in scripts/smoke/ with documentation in docs/smoke-test.md:

pnpm smoke
./scripts/smoke-test.sh list
./scripts/smoke-test.sh step-5-tunnel-local

Caveats

Captun is intentionally small. It is a reference implementation you can copy into a Worker or Durable Object, not a managed tunnel product.

It is fast but less durable than Cloudflare Tunnel. There is no redundant connection in another data center, and a connected Durable Object can still be restarted, so an in-flight request can fail.

Large binary streams are slower than small requests because a Response body crosses the Cap'n Web WebSocket/RPC session rather than getting spliced as a native HTTP socket. For webhook callbacks, mocked internet egress, local previews, and e2e tests, that tradeoff is usually fine.

Connecting a second client with the same tunnel name replaces the previous connection. Malformed percent-encoding in a folder tunnel name is rejected as a missing tunnel name.

About

Minimal Cap'n Web tunnel for Cloudflare Workers and Node

Resources

Stars

23 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages