Tunnels
Iterate runs its own small captun gateway in apps/tunnels, deployed at
https://tunnels.iterate.com. It gives local processes and CI jobs public
HTTPS URLs without embedding test or callback routes into product workers.
Use it when something outside your machine needs to call back into code that is running locally or inside a CI runner:
- Slack/GitHub webhooks
- OAuth callbacks during local development
- e2e fixtures that deployed Workers must reach
- browser or agent access to a local OS dev server
How It Works#
A client opens a long-lived captun connection to the gateway using
CAPTUN_TOKEN. The gateway routes public requests for a tunnel name back over
that connection. With CUSTOM_HOSTNAME=tunnels.iterate.com, named tunnels use
subdomains:
https://<name>.tunnels.iterate.comCAPTUN_TOKEN lives in Doppler shared dev/preview configuration. In normal
repo use, run tunnel commands inside Doppler instead of exporting the token by
hand:
doppler run --project os --config dev -- ...The installed captun package uses the Iterate Cap'n Web fork from
https://github.com/iterate/capnweb/releases/download/v0.8.0-websocket.1/capnweb-0.8.0.tgz.
That build supports forwarding WebSocket upgrade responses over Cap'n Web, so
tunneled URLs work for both HTTP and WebSockets.
OS Dev Server#
For the OS Vite dev server, set a stable tunnel name:
CAPTUN_TUNNEL_NAME=jonas pnpm devThe captun Vite plugin in apps/os/vite.config.ts publishes the dev server at:
https://jonas.tunnels.iterate.comUse a stable personal name for webhook and OAuth work, because third-party apps usually store exactly one callback URL.
Programmatic Fixtures#
OS e2e tests should use withTunnel() from
apps/os/e2e/test-support/tunnel.ts. It returns a loopback URL for local dev
targets and a captun URL when APP_CONFIG_BASE_URL points at a deployed
worker:
import { withTunnel } from "../test-support/tunnel.ts";
const fixture = await withTunnel({
path: "/mock-api",
fetch(request) {
return Response.json({ ok: true, url: request.url });
},
});
try {
console.log(fixture.url);
} finally {
await fixture.close();
}Other tests and scripts can create a named public fixture directly without opening a local port:
import { createCaptunTunnel } from "captun";
const tunnel = await createCaptunTunnel({
gateway: process.env.CAPTUN_GATEWAY || "https://tunnels.iterate.com",
name: "my-fixture",
token: process.env.CAPTUN_TOKEN,
fetch(request) {
return Response.json({ ok: true, url: request.url });
},
});
console.log(tunnel.url); // https://my-fixture.tunnels.iterate.com
// Later:
tunnel[Symbol.dispose]();Omit name for a random tunnel. Prefer random names for automated tests unless
a third-party integration requires a stable URL.
CI#
Depot jobs can use the same gateway. The workflow needs DOPPLER_TOKEN, then
the job should enter a config that contains CAPTUN_TOKEN:
- name: Captun-backed test
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }}
run: doppler run --project os --config dev -- pnpm e2eFor preview e2e tests, prefer Doppler-managed preview/shared config so the runner and the deployed worker agree on the gateway and secrets.