Skip to content

Tunnels

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.

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.

TypeScript
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.

JavaScript
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 });
},
};

tunnels.list()

Return every tunnel currently tracked for this sandbox.

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

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

JavaScript
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.

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

Parameters:

  • portOrInfo — Either the port number or the TunnelInfo record returned by get().
JavaScript
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.

FieldTypeDescription
idstringTunnel identifier. quick-<random> for quick tunnels, the Cloudflare Tunnel UUID for named tunnels.
portnumberPort number inside the sandbox that the tunnel proxies to.
urlstringPublic URL — https://<random>.trycloudflare.com (quick) or https://<name>.<your-zone> (named).
hostnamestringHostname component of url.
createdAtstringISO-8601 timestamp of when the tunnel was created.
namestringNamed tunnels only. The label passed via options.name. Absent on quick tunnels.
TypeScript
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

AspectQuick tunnelNamed tunnel
HostnameRandom *.trycloudflare.com, assigned by Cloudflare<name>.<your-zone>, chosen by you
StabilityChanges on every container restartStable; persists across restarts and sandbox lifecycles
Cloudflare accountNot requiredRequired (API token + zone)
Cloudflare-side resourcesNoneManaged Cloudflare Tunnel + proxied DNS CNAME
Uptime guaranteeNone (debug aid)Backed by your zone's standard Cloudflare SLA
TLS certificateCloudflare-owned wildcardUniversal SSL on <name>.<your-zone> (single DNS label only)
Server-Sent EventsNot 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:

ScopeUsed for
Account · Cloudflare Tunnel · EditCreate, look up, and delete tunnels.
Zone · DNS · EditUpsert and delete the proxied CNAME for <name>.<your-zone>.
Zone · Zone · ReadLook 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.

Terminal window
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.

VariableRequired?Notes
CLOUDFLARE_API_TOKENYesStore as a secret with wrangler secret put.
CLOUDFLARE_ACCOUNT_IDOnly if the token sees multiple accountsInferred from the token otherwise.
CLOUDFLARE_ZONE_IDOnly if the token sees multiple zonesInferred from the token otherwise.
Terminal window
npx wrangler secret put CLOUDFLARE_API_TOKEN

For local development, place the variables in .dev.vars (gitignored). For production, set the non-secret IDs (when needed) under vars in your Wrangler config:

JSONC
{
"vars": {
"CLOUDFLARE_ACCOUNT_ID": "<account-id>",
"CLOUDFLARE_ZONE_ID": "<zone-id>"
}
}

When inference fails, the SDK throws a clear error naming the variable to set.

Example

JavaScript
import { getSandbox } from "@cloudflare/sandbox";
export { Sandbox } from "@cloudflare/sandbox";
export default {
async fetch(request, env) {
const sandbox = getSandbox(env.Sandbox, "my-sandbox");
// Reuse an existing app process across container restarts, or start it.
let proc = await sandbox.getProcess("app");
if (!proc) {
try {
proc = await sandbox.startProcess("python -m http.server 8080", {
processId: "app",
});
} catch (err) {
if (err?.code !== "PROCESS_ALREADY_EXISTS") throw err;
proc = await sandbox.getProcess("app");
}
}
// Provision (or reuse) https://app.example.com pointing at port 8080.
const tunnel = await sandbox.tunnels.get(8080, { name: "app" });
console.log(tunnel.url); // → https://app.example.com
return Response.json({ url: tunnel.url });
},
};

Lifecycle

Named tunnels are designed to outlive the container that provisioned them:

  1. First call to sandbox.tunnels.get(port, { name }):
    • Resolves <name>.<your-zone> from the configured zone ID.
    • Creates a Cloudflare Tunnel resource named sandbox-<sandbox-id>-<name> tagged with the sandbox ID.
    • Upserts a proxied CNAME from <name>.<your-zone> to <tunnel-id>.cfargotunnel.com.
    • Spawns cloudflared inside the container with the tunnel's token.
  2. Subsequent calls with the same (port, name) return the cached record without contacting Cloudflare.
  3. Container restart (Durable Object eviction, deploy, crash):
    • cloudflared dies with the container, but the Cloudflare Tunnel and DNS record are preserved.
    • On the next get(port, { name }), the SDK rediscovers the tagged tunnel via the Cloudflare API and respawns cloudflared. The hostname is unchanged.
  4. Explicit teardown with sandbox.tunnels.destroy(port):
    • Stops cloudflared inside the container.
    • Deletes the Cloudflare Tunnel resource.
    • Deletes the proxied CNAME record.
  5. Sandbox destroy with sandbox.destroy() tears down every tunnel the sandbox provisioned, including the Cloudflare-side resources, before stopping the container.

If destroy() fails to reach the Cloudflare API (for example, the token was revoked between get() and destroy()), the SDK logs a warning naming the orphaned tunnelId and dnsRecordId so you can clean up manually from the dashboard.

Cloudflare resources and tagging

Named tunnels create resources on your Cloudflare account, outside the sandbox container. They are not stored in Durable Object storage and do not count against sandbox quotas, but they do show up in the Cloudflare dashboard and consume your account's tunnel and DNS quotas.

For each (sandbox, name) pair, the SDK creates:

ResourceName / locationIdentifier
Cloudflare TunnelZero Trust > Networks > Tunnelssandbox-<sandbox-id>-<name>
Proxied DNS recordYour zone, DNS > RecordsCNAME <name>.<zone> → <tunnel-id>.cfargotunnel.com

Both resources are tagged so you can audit, query, and bulk-clean them from the dashboard or API:

  • Tunnel metadata: { sandboxId, createdBy: 'sandbox-sdk', name, port }
  • DNS record comment: sandbox-<sandbox-id>
  • Resource tag (Enterprise plans only): sandboxId:<sandbox-id>

On non-Enterprise plans, Cloudflare rejects resource tags; the SDK detects this and retries the request without tags. The DNS comment and tunnel metadata still apply, so you can always trace a resource back to its sandbox.

To list every tunnel created by the SDK for a given account:

Terminal window
curl "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/cfd_tunnel?name=sandbox-" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

Limitations

Both tunnel flavors:

  • WARP / Zero Trust egress. If your local machine runs Cloudflare WARP or another Zero Trust egress policy, outbound traffic to api.trycloudflare.com and the cloudflared edge can be blocked. When that happens, tunnels.get() hangs on the edge handshake and eventually times out. Disable WARP or add an egress exception for these destinations.
  • Brief DNS warm-up. The first request through a brand-new URL can take a couple of seconds while DNS propagates, even after get() resolves.

Quick tunnels only:

  • URLs do not survive container restart. Cloudflare assigns the hostname during cloudflared's startup handshake, so every restart yields a new URL. The SDK clears its tunnel cache when the container starts, so the next tunnels.get(port) returns a fresh record. Use a named tunnel for a stable hostname.
  • No uptime guarantee. Cloudflare positions trycloudflare.com as a debug aid, not a production target.
  • No Server-Sent Events. The trycloudflare.com edge buffers text/event-stream responses, so SSE events never reach the client. WebSockets work normally. Use a named tunnel if your service streams SSE.

Named tunnels only:

  • Single DNS label. name must not contain dots. Universal SSL only covers <name>.<your-zone>.
  • Counts against your zone's quotas. Each named tunnel creates a Cloudflare Tunnel and a DNS record on your account. See Cloudflare Tunnel limits.
  • Cleanup requires the API token. If destroy() runs after the token has been revoked, the Cloudflare-side resources are orphaned. The SDK logs the orphan IDs so you can remove them manually.