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.
// Extract hostname from requestconst { 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/...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/...
When no custom token is specified, a random 16-character token is generated:
const exposed = await sandbox.exposePort(8000, { hostname });// https://8000-sandbox-id-abc123random4567.yourdomain.comURLs with auto-generated tokens change when you unexpose and re-expose a port.
For production deployments or shared URLs, specify a custom token to maintain consistency across container restarts:
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
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.
// Problem scenarioconst 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:
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.
You must call proxyToSandbox() first in your Worker's fetch handler to route preview URL requests:
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.
Expose multiple services simultaneously:
// Extract hostname from requestconst { hostname } = new URL(request.url);
await sandbox.startProcess("node api.js"); // Port 3000await 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- HTTP/HTTPS requests
- WebSocket connections
- Server-Sent Events
- All HTTP methods (GET, POST, PUT, DELETE, etc.)
- Request and response headers
- Raw TCP/UDP connections
- Custom protocols (must wrap in HTTP)
- Ports outside range 1024-65535
- Port 3000 (used internally by the SDK)
Preview URLs support WebSocket connections. When a WebSocket upgrade request hits an exposed port, the routing layer automatically handles the connection handshake.
// Extract hostname from requestconst { hostname } = new URL(request.url);
// Start a WebSocket serverawait 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 automaticallyexport 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.
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:
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.
Check if service is running and listening:
// 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));For custom domain issues, see Production Deployment troubleshooting.
- Production Deployment - Set up custom domains for production
- Expose Services - Practical patterns for exposing ports
- Ports API - Complete API reference
- Security Model - Security best practices