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.comhostname for each newcloudflaredprocess. 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 samename. 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.tunnelson 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 on0.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 theTunnelInforecord returned byget().
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:
- A Cloudflare account with a zone (a domain you control on Cloudflare DNS).
- A Cloudflare API token with the correct scopes.
- 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.
