Skip to content
Docs

Preview URLs

Quick deployment

For quick preview deployments we recommend using Cloudflare Tunnel to generate preview URLs to your web services. These work across local development, workers.dev and production usage.

TypeScript
await sandbox.startProcess("python -m http.server 8000");
const tunnel = await sandbox.tunnels.get(8000);
console.log(tunnel.url);
// https://acute-llama-dancing-roundly.trycloudflare.app
// Request will be routed directly to the webserver running on the sandbox.
const req = await fetch(`${tunnel.url}/api/users`); // => GET http://localhost:8000/api/users

Cloudflare Tunnel support currently has the following limitations:

  • No control over generated URL.
  • No authentication mechanism beyond randomly generated URL.
  • Each URL uses an additional cloudflared process on the sandbox.

See the tunnels API reference for the full API and feature set.

Production usage, stable URLs & custom domains

For production use we recommend using the exposePort() API and routing traffic through your worker.

Preview URLs provide public HTTPS access to services running inside sandboxes. When you expose a port, you get a unique URL that proxies requests to your service.

TypeScript
// Extract hostname from request
const { hostname } = new URL(request.url);
await sandbox.startProcess("python -m http.server 8000");
const exposed = await sandbox.exposePort(8000, { hostname });
console.log(exposed.url);
// Production: https://8000-sandbox-id-abc123random4567.yourdomain.com
// Local dev: http://8000-sandbox-id-abc123random4567.localhost:{port}/

URL Format

Production: https://{port}-{sandbox-id}-{token}.yourdomain.com

  • With auto-generated token: https://8080-abc123-random16chars12.yourdomain.com
  • With custom token: https://8080-abc123-my_api_v1.yourdomain.com

Local development: http://{port}-{sandbox-id}-{token}.localhost:{dev-server-port}

Token Types

Auto-generated tokens (default)

When no custom token is specified, a random 16-character token is generated:

TypeScript
const exposed = await sandbox.exposePort(8000, { hostname });
// https://8000-sandbox-id-abc123random4567.yourdomain.com

URLs with auto-generated tokens change when you unexpose and re-expose a port.

Custom tokens for stable URLs

For production deployments or shared URLs, specify a custom token to maintain consistency across container restarts:

TypeScript
const stable = await sandbox.exposePort(8000, {
hostname,
token: "api_v1",
});
// https://8000-sandbox-id-api_v1.yourdomain.com
// Same URL every time ✓

Token requirements:

  • 1-16 characters long
  • Lowercase letters (a-z), numbers (0-9), and underscores (_) only
  • Must be unique within each sandbox

Use cases for custom tokens:

  • Production APIs with stable endpoints
  • Sharing demo URLs with external users
  • Documentation with consistent examples
  • Integration testing with predictable URLs

ID Case Sensitivity

Preview URLs extract the sandbox ID from the hostname to route requests. Since hostnames are case-insensitive (per RFC 3986), they're always lowercased: 8080-MyProject-123.yourdomain.com becomes 8080-myproject-123.yourdomain.com.

The problem: If you create a sandbox with "MyProject-123", it exists as a Durable Object with that exact ID. But the preview URL routes to "myproject-123" (lowercased from the hostname). These are different Durable Objects, so your sandbox is unreachable via preview URL.

TypeScript
// Problem scenario
const sandbox = getSandbox(env.Sandbox, "MyProject-123");
// Durable Object ID: "MyProject-123"
await sandbox.exposePort(8080, { hostname });
// Preview URL: 8080-myproject-123-token123.yourdomain.com
// Routes to: "myproject-123" (different DO - doesn't exist!)

The solution: Use normalizeId: true to lowercase IDs when creating sandboxes:

TypeScript
const sandbox = getSandbox(env.Sandbox, "MyProject-123", {
normalizeId: true,
});
// Durable Object ID: "myproject-123" (lowercased)
// Preview URL: 8080-myproject-123-token123.yourdomain.com
// Routes to: "myproject-123" (same DO - works!)

Without normalizeId: true, exposePort() throws an error when the ID contains uppercase letters.

Best practice: Use lowercase IDs from the start ('my-project-123'). See Sandbox options - normalizeId for details.

Request Routing

You must call proxyToSandbox() first in your Worker's fetch handler to route preview URL requests:

TypeScript
import { proxyToSandbox, getSandbox } from "@cloudflare/sandbox";
export { Sandbox } from "@cloudflare/sandbox";
export default {
async fetch(request, env) {
// Handle preview URL routing first
const proxyResponse = await proxyToSandbox(request, env);
if (proxyResponse) return proxyResponse;
// Your application routes
// ...
},
};

Requests flow: Browser → Your Worker → Durable Object (sandbox) → Your Service.

Multiple Ports

Expose multiple services simultaneously:

TypeScript
// Extract hostname from request
const { hostname } = new URL(request.url);
await sandbox.startProcess("node api.js"); // Port 3000
await sandbox.startProcess("node admin.js"); // Port 3001
const api = await sandbox.exposePort(3000, { hostname, name: "api" });
const admin = await sandbox.exposePort(3001, { hostname, name: "admin" });
// Each gets its own URL with unique tokens:
// https://3000-abc123-random16chars01.yourdomain.com
// https://3001-abc123-random16chars02.yourdomain.com

What Works

  • HTTP/HTTPS requests
  • WebSocket connections
  • Server-Sent Events
  • All HTTP methods (GET, POST, PUT, DELETE, etc.)
  • Request and response headers

What Does Not Work

  • Raw TCP/UDP connections
  • Custom protocols (must wrap in HTTP)
  • Ports outside range 1024-65535
  • Port 3000 (used internally by the SDK)

WebSocket Support

Preview URLs support WebSocket connections. When a WebSocket upgrade request hits an exposed port, the routing layer automatically handles the connection handshake.

TypeScript
// Extract hostname from request
const { hostname } = new URL(request.url);
// Start a WebSocket server
await sandbox.startProcess("bun run ws-server.ts 8080");
const { url } = await sandbox.exposePort(8080, { hostname });
// Clients connect using WebSocket protocol
// Browser: new WebSocket('wss://8080-abc123-token123.yourdomain.com')
// Your Worker routes automatically
export default {
async fetch(request, env) {
const proxyResponse = await proxyToSandbox(request, env);
if (proxyResponse) return proxyResponse;
},
};

For custom routing scenarios where your Worker needs to control which sandbox or port to connect to based on request properties, see wsConnect() in the Ports API.

Security

Built-in security:

  • Token-based access - Each exposed port gets a unique token in the URL (for example, https://8080-sandbox-abc123token456.yourdomain.com)
  • HTTPS in production - All traffic is encrypted with TLS. Certificates are provisioned automatically for first-level wildcards (*.yourdomain.com). If your worker runs on a subdomain, see the TLS note in Production Deployment.
  • Unpredictable URLs - Auto-generated tokens are randomly generated and difficult to guess
  • Token collision prevention - Custom tokens are validated to ensure uniqueness within each sandbox

Add application-level authentication:

For additional security, implement authentication within your application:

Python
from flask import Flask, request, abort
app = Flask(__name__)
@app.route('/data')
def get_data():
# Check for your own authentication token
auth_token = request.headers.get('Authorization')
if auth_token != 'Bearer your-secret-token':
abort(401)
return {'data': 'protected'}

This adds a second layer of security on top of the URL token.

Troubleshooting

URL Not Accessible

Check if service is running and listening:

TypeScript
// 1. Is service running?
const processes = await sandbox.listProcesses();
// 2. Is port exposed?
const ports = await sandbox.getExposedPorts();
// 3. Is service binding to 0.0.0.0 (not 127.0.0.1)?
// Good:
app.run((host = "0.0.0.0"), (port = 3000));
// Bad (localhost only):
app.run((host = "127.0.0.1"), (port = 3000));

Production Errors

For custom domain issues, see Production Deployment troubleshooting.

Local Development