Tunnels

Last updated Sep 30, 2026 Note

This page documents Sandbox SDK 0.x for existing applications. For new applications, refer to Sandboxes. To move an existing application to @cloudflare/sandbox 1.0, refer to Migrate from Sandbox SDK 0.x.

The sandbox.tunnels namespace exposes a service running inside a sandbox on the public internet through a Cloudflare Tunnel. The SDK runs cloudflared inside the container and opens a persistent QUIC connection to Cloudflare's edge.

Two flavors are available:

  • Quick tunnels ( sandbox.tunnels.get(port)) — zero-config. Cloudflare assigns a random *.trycloudflare.com hostname for each new cloudflared process. No Cloudflare account, API token, DNS record, or custom domain required. URLs change on every container restart.
  • Named tunnels ( sandbox.tunnels.get(port, { name })) — bind a stable hostname <name>.<your-zone> on a zone you control. The hostname survives container restarts and is shared across sandboxes that request the same name. Requires a Cloudflare API token, an account, and a zone.

When to use quick vs. named tunnels

Use quick tunnels for local development, demos, and short-lived .workers.dev deployments where you do not need a stable URL. Use named tunnels for everything else — they are the recommended option for production traffic, webhook receivers, OAuth callbacks, and any URL that needs to be bookmarked. exposePort() remains an alternative when you want the Worker itself (rather than Cloudflare's edge) to front the request.

Requirements

Both tunnel flavors require:

  • RPC transport. Calling sandbox.tunnels on HTTP/Websocket transports throws "RPC transport required". See Transport configuration.

Named tunnels additionally require a Cloudflare API token, account, and zone — refer to Named tunnels: prerequisites.

Methods

tunnels.get()

Return a tunnel record for port. The SDK spawns a fresh cloudflared process inside the container if not already running. The method is idempotent: repeated calls with the same (port, options) return the same record.

const tunnel = await sandbox.tunnels.get(
  port: number,
  options?: { name?: string }
): Promise<TunnelInfo>

Parameters:

  • port — Port number inside the sandbox to expose (1024-65535, excluding reserved ports). The service to tunnel to must already be listening on 0.0.0.0:<port> inside the container.
  • options.name (optional) — Single DNS label (lowercase letters, digits, internal hyphens; 1–63 chars; no dots). When set, provisions a named tunnel bound to <name>.<your-zone>. When omitted, provisions a quick tunnel.

Returns: Promise<TunnelInfo> — the tunnel record. See TunnelInfo.

Calling get(port) with different options on a port that already has a tunnel throws. Call destroy(port) first.

import { getSandbox } from "@cloudflare/sandbox";

export { Sandbox } from "@cloudflare/sandbox";

export default {
    async fetch(request, env) {
        const sandbox = getSandbox(env.Sandbox, "my-sandbox");

await sandbox.startProcess("python -m http.server 8080");

const tunnel = await sandbox.tunnels.get(8080);
        console.log(tunnel.url);
        // → https://random-words-here.trycloudflare.com

// Repeated calls for the same port return the same record.
        const same = await sandbox.tunnels.get(8080);
        console.log(same.url === tunnel.url); // true

return Response.json({ url: tunnel.url });
    },
};
import { getSandbox } from "@cloudflare/sandbox";

export { Sandbox } from "@cloudflare/sandbox";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const sandbox = getSandbox(env.Sandbox, "my-sandbox");

await sandbox.startProcess("python -m http.server 8080");

const tunnel = await sandbox.tunnels.get(8080);
    console.log(tunnel.url);
    // → https://random-words-here.trycloudflare.com

// Repeated calls for the same port return the same record.
    const same = await sandbox.tunnels.get(8080);
    console.log(same.url === tunnel.url); // true

return Response.json({ url: tunnel.url });

},
};

tunnels.list()

Return every tunnel currently tracked for this sandbox.

const tunnels = await sandbox.tunnels.list(): Promise<TunnelInfo[]>

Returns: Promise<TunnelInfo[]> — an array of TunnelInfo records. Empty when no tunnels are active.

const tunnels = await sandbox.tunnels.list();

for (const tunnel of tunnels) {
    console.log(`port ${tunnel.port} → ${tunnel.url}`);
}
const tunnels = await sandbox.tunnels.list();

for (const tunnel of tunnels) {
console.log(`port ${tunnel.port} → ${tunnel.url}`);
}

tunnels.destroy()

Tear down a tunnel. Accepts either the port number or the TunnelInfo record returned by get(). Idempotent — destroying an unknown port resolves successfully.

await sandbox.tunnels.destroy(portOrInfo: number | TunnelInfo): Promise<void>

Parameters:

  • portOrInfo — Either the port number or the TunnelInfo record returned by get().
const tunnel = await sandbox.tunnels.get(8080);

// Tear down by port number...
await sandbox.tunnels.destroy(8080);

// ...or by the record.
await sandbox.tunnels.destroy(tunnel);
const tunnel = await sandbox.tunnels.get(8080);

// Tear down by port number...
await sandbox.tunnels.destroy(8080);

// ...or by the record.
await sandbox.tunnels.destroy(tunnel);

Types

TunnelInfo

Quick tunnels omit name; named tunnels carry the label passed via options.name.

Field Type Description
id string Tunnel identifier. quick-<random> for quick tunnels, the Cloudflare Tunnel UUID for named tunnels.
port number Port number inside the sandbox that the tunnel proxies to.
url string Public URL — https://<random>.trycloudflare.com (quick) or https://<name>.<your-zone> (named).
hostname string Hostname component of url.
createdAt string ISO-8601 timestamp of when the tunnel was created.
name string Named tunnels only. The label passed via options.name. Absent on quick tunnels.
type TunnelInfo = QuickTunnelInfo | NamedTunnelInfo;

interface QuickTunnelInfo {
  id: string;
  port: number;
  url: string;
  hostname: string;
  createdAt: string;
  name?: never;
}

interface NamedTunnelInfo {
  id: string;
  port: number;
  url: string;
  hostname: string;
  createdAt: string;
  name: string;
}

Named tunnels

Named tunnels bind a user-controlled hostname — <name>.<your-zone> — backed by a managed Cloudflare Tunnel and a proxied CNAME record on your zone. Unlike quick tunnels, the URL is stable across container restarts and shared across sandboxes that call get(port, { name }) with the same name.

How they differ from quick tunnels

Aspect Quick tunnel Named tunnel
Hostname Random *.trycloudflare.com, assigned by Cloudflare <name>.<your-zone>, chosen by you
Stability Changes on every container restart Stable; persists across restarts and sandbox lifecycles
Cloudflare account Not required Required (API token + zone)
Cloudflare-side resources None Managed Cloudflare Tunnel + proxied DNS CNAME
Uptime guarantee None (debug aid) Backed by your zone's standard Cloudflare SLA
TLS certificate Cloudflare-owned wildcard Universal SSL on <name>.<your-zone> (single DNS label only)
Server-Sent Events Not supported (edge buffers text/event-stream) Supported

Prerequisites

To provision a named tunnel, you need:

  1. A Cloudflare account with a zone (a domain you control on Cloudflare DNS).
  2. A Cloudflare API token with the correct scopes.
  3. The account ID and zone ID — the SDK can infer both from the token when the token is scoped to exactly one of each.

Create the API token

Create a token from My Profile > API Tokens > Create Token > Custom token with the following permissions:

Scope Used for
Account · Cloudflare Tunnel · Edit Create, look up, and delete tunnels.
Zone · DNS · Edit Upsert and delete the proxied CNAME for <name>.<your-zone>.
Zone · Zone · Read Look up the zone's name to derive <name>.<your-zone>.
Account · Account Settings · Read (optional) Lets the SDK infer the account ID from the token when not set explicitly.

Under Account Resources, scope the token to the account that will own the tunnel. Under Zone Resources, scope it to the specific zone you want to bind to.

Both User API Tokens and Account API Tokens (secret prefixed cfat_) are supported — the SDK detects the token kind and uses the appropriate introspection endpoint.

Create the token with the REST API

You can create the token without using the dashboard. The permission group IDs are stable; the snippet below uses placeholders — fetch the current IDs from GET /user/tokens/permission_groups.

curl -X POST "https://api.cloudflare.com/client/v4/user/tokens" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "sandbox-named-tunnels",
    "policies": [
      {
        "effect": "allow",
        "resources": { "com.cloudflare.api.account.<ACCOUNT_ID>": "*" },
        "permission_groups": [{ "id": "<TUNNEL_EDIT_GROUP_ID>" }]
      },
      {
        "effect": "allow",
        "resources": { "com.cloudflare.api.account.zone.<ZONE_ID>": "*" },
        "permission_groups": [
          { "id": "<DNS_EDIT_GROUP_ID>" },
          { "id": "<ZONE_READ_GROUP_ID>" }
        ]
      }
    ]
  }'

Bind the token and IDs to the Worker

The SDK reads CLOUDFLARE_API_TOKEN from the Worker environment and attempts to derive the account ID and zone ID from the token automatically. If the token is associated with multiple accounts or zones the SDK cannot pick one unambiguously, and you must set CLOUDFLARE_ACCOUNT_ID and/or CLOUDFLARE_ZONE_ID explicitly.