Skip to content
Cloudflare Docs

Preview URLs

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://localhost:8787/...

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://localhost:8787/...

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), hyphens (-), 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