---
title: Preview URLs
description: Sandbox SDK preview URLs provide public HTTPS access to services running inside sandboxes.
image: https://developers.cloudflare.com/dev-products-preview.png
---

> Documentation Index  
> Fetch the complete documentation index at: https://developers.cloudflare.com/sandbox/llms.txt  
> Use this file to discover all available pages before exploring further.

[Skip to content](#%5Ftop) 

# Preview URLs

Production requires custom domain

Preview URLs work in local development without configuration. For production, you need a custom domain with wildcard DNS routing. See [Production Deployment](https://developers.cloudflare.com/sandbox/guides/production-deployment/).

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](https://developers.cloudflare.com/sandbox/configuration/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](https://developers.cloudflare.com/sandbox/api/ports/#wsconnect).

## Security

Warning

Preview URLs are publicly accessible by default, but require a valid access token that is generated when you expose a port.

**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](https://developers.cloudflare.com/sandbox/guides/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](https://developers.cloudflare.com/sandbox/guides/production-deployment/#troubleshooting).

### Local Development

Local development limitation

When using `wrangler dev`, you must expose ports in your Dockerfile:

```

FROM docker.io/cloudflare/sandbox:0.3.3


# Required for local development

EXPOSE 3000

EXPOSE 8080


```

Without `EXPOSE`, you'll see: `connect(): Connection refused: container port not found`

This is **only required for local development**. In production, all container ports are automatically accessible.

## Related Resources

* [Production Deployment](https://developers.cloudflare.com/sandbox/guides/production-deployment/) \- Set up custom domains for production
* [Expose Services](https://developers.cloudflare.com/sandbox/guides/expose-services/) \- Practical patterns for exposing ports
* [Ports API](https://developers.cloudflare.com/sandbox/api/ports/) \- Complete API reference
* [Security Model](https://developers.cloudflare.com/sandbox/concepts/security/) \- Security best practices

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/sandbox/","name":"Sandbox SDK"}},{"@type":"ListItem","position":3,"item":{"@id":"/sandbox/concepts/","name":"Concepts"}},{"@type":"ListItem","position":4,"item":{"@id":"/sandbox/concepts/preview-urls/","name":"Preview URLs"}}]}
```
