---
description: Sandbox SDK 0.x preview URLs provide public HTTPS access to services running inside sandboxes.
title: Preview URLs
image: https://developers.cloudflare.com/sandbox/sdk/concepts/preview-urls/og.png?v=65026b2cf6796ba8
---

[Skip to content](#main-content)

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

# Preview URLs

Last updated Sep 30, 2026|Copy as Markdown| [View as Markdown](https://developers.cloudflare.com/sandbox/sdk/concepts/preview-urls/index.md)| [Agent setup](https://developers.cloudflare.com/agent-setup/)

Note

This page documents Sandbox SDK 0.x for existing applications. For new applications, refer to [Preview a web application](https://developers.cloudflare.com/sandbox/previews/). To move an existing application to `@cloudflare/sandbox` 1.0, refer to [Migrate from Sandbox SDK 0.x](https://developers.cloudflare.com/sandbox/sdk/migrate/).

# Quick deployment

For quick preview deployments we recommend using [Cloudflare Tunnel ↗︎](https://developers.cloudflare.com/tunnel/) to generate preview URLs to your web services. These work across local development, workers.dev and production usage.

```ts
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.

Production requires custom domain

We are working on production deployments, custom hostnames and authentication for Cloudflare Tunnel support. In the mean time we recommend using `exposePort()` and `proxyToSandbox()` documented below under Production usage, stable URLs and custom domains.

See the [tunnels API reference](https://developers.cloudflare.com/sandbox/sdk/api/tunnels/) 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.

Custom domain for exposePort production URLs

Local development does not need a custom domain or wildcard DNS for `exposePort()` preview URLs. For production, you need a custom domain with wildcard DNS routing. Refer to [Configure preview URLs on a custom domain](https://developers.cloudflare.com/sandbox/sdk/guides/preview-urls-custom-domain/).

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/sdk/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/sdk/api/ports/#wsconnect).

## Security

Caution

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, refer to the [TLS note for custom domains](https://developers.cloudflare.com/sandbox/sdk/guides/preview-urls-custom-domain/#subdomain-depth-matters-for-tls).
- **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, refer to [preview URL custom domain troubleshooting](https://developers.cloudflare.com/sandbox/sdk/guides/preview-urls-custom-domain/#troubleshooting).

### Local Development

Local development limitation

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

```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

- [Configure preview URLs on a custom domain](https://developers.cloudflare.com/sandbox/sdk/guides/preview-urls-custom-domain/) - Wildcard DNS and TLS for `exposePort()`
- [Deploy a Sandbox application](https://developers.cloudflare.com/sandbox/sdk/guides/deploy/) - Worker and container image deploys
- [Expose Services](https://developers.cloudflare.com/sandbox/sdk/guides/expose-services/) - Practical patterns for exposing ports
- [Ports API](https://developers.cloudflare.com/sandbox/sdk/api/ports/) - Complete API reference
- [Tunnels API](https://developers.cloudflare.com/sandbox/sdk/api/tunnels/) - Zero-config `*.trycloudflare.com` URLs as an alternative for development
- [Security Model](https://developers.cloudflare.com/sandbox/sdk/concepts/security/) - Security best practices

Was this helpful?

YesNo

## On this page

[![](https://developers.cloudflare.com/_astro/logo.te5VL_aD.svg)Docs](https://developers.cloudflare.com/)

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/sandbox/sdk/concepts/preview-urls/#page","headline":"Preview URLs","description":"Sandbox SDK 0.x preview URLs provide public HTTPS access to services running inside sandboxes.","url":"https://developers.cloudflare.com/sandbox/sdk/concepts/preview-urls/","inLanguage":"en","image":"https://developers.cloudflare.com/sandbox/sdk/concepts/preview-urls/og.png?v=65026b2cf6796ba8","dateModified":"2026-09-30","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
```
